Skip to content

Airline Blocks

Airline-block inventory — bulk pre-purchased seats on scheduled flights, held as stock on the books until they are consumed by a group booking or sold to a B2B partner. This is the flight-side counterpart to the Hotels module.

What is an airline block?

An airline block (internally AirlineQuotaBlock) is a bulk seat purchase made directly from an airline at a fixed PNR — typically a negotiated group fare. Each block sits on the balance sheet as stock (Dr Stock-in-Hand / Cr Supplier Payable) at creation. Seats are moved to Purchase A/c via COGS when they go out — either to a group booking (GroupFlight link) or to a B2B partner sale (B2BFlightOffer). Source: the in-app HelpBanner in src/pages/inventory/AirlineBlocks.tsx (line ~2348).

What it manages

  • Blocks — airline + PNR + trip type (one_way / return / multi_city) + totalSeats / allocatedSeats / availableSeats + price/seat + currency + expiry date + status.
  • Legs — structured flight segments stored as JSON in AirlineQuotaBlock.legs and AirlineQuotaBlock.returnLegs (added in supabase/migrations/20260330120000_quota_block_legs.sql). Each leg carries origin, destination, terminal, arrivalTerminal, flightNo, departDate, departTime, arriveDate, arriveTime, and an optional per-leg pnr.
  • B2B seat sales — the page includes a "Sell Seats" flow that creates B2BFlightOffer rows against a block, lowering availableSeats and creating an invoice on the partner ledger.
  • Supplier payments / finance events — recorded against the airline supplier via postAirlineBlockFinanceEvent in src/services/airlineBlockService.ts.
  • FIT inventory — the related FIT (Free Individual Traveller) table lives on a sibling page at src/pages/inventory/FITInventory.tsx for individually-sold tickets rather than bulk blocks.
  • Ground transfers — another sibling inventory type on src/pages/inventory/GroundTransfers.tsx.

Pages

  • src/pages/inventory/AirlineBlocks.tsx — the primary page. Both list/detail modes live here; the detail page reuses it via autoOpenBlockId.
  • src/pages/inventory/AirlineBlockDetailPage.tsx — thin 7-line wrapper that auto-opens a specific block by URL param (/inventory/quota/:id).
  • src/pages/inventory/FITInventory.tsx + FITDetailPage.tsx — FIT inventory (same sibling pattern).
  • src/pages/inventory/B2BFlights.tsx — B2B offer listings fed by airline blocks.
  • src/pages/inventory/GroundTransfers.tsx — ground-transfer inventory.

Routes (from src/App.tsx):

Path Component
/inventory/quota AirlineBlocks
/inventory/quota/:id AirlineBlockDetailPage
/inventory/fit FITInventory
/inventory/fit/:id FITDetailPage
/inventory/b2b-flights B2BFlights
/inventory/ground-transfers GroundTransfers

Bundle size

AirlineBlocks is the heaviest inventory chunk at roughly 29.4 KB gzipped (budget 34 KB per scripts/check-bundle-budgets.mjs). The file is ~4,300 lines because both the list view and the full create/edit/view/sell-seats dialog tree live in one component.

Multi-leg route handling (chainLegs)

A single block can have 1..N outbound legs and 0..N return legs. UI code chains them into a display route by walking the legs array. The helper lives in src/pages/groups/Groups.tsx (line 3579) and is the same logic used in group summaries:

const chainLegs = (legs: any[] | undefined, fallbackFrom?: string, fallbackTo?: string): string => {
  if (Array.isArray(legs) && legs.length > 0) {
    const airports: string[] = [];
    const first = legs[0];
    if (first?.origin) airports.push(first.origin);
    for (const leg of legs) {
      if (leg?.destination) airports.push(leg.destination);
    }
    return airports.filter(Boolean).join(' → ');
  }
  return fallbackFrom && fallbackTo ? `${fallbackFrom}${fallbackTo}` : '';
};

Semantics:

  1. Start with the first leg's origin, then append every leg's destination in order.
  2. Result is a A → B → C → D chain (e.g. BLR → DOH → JED for a BLR→JED with a DOH stopover).
  3. If legs is empty/missing, fall back to the block's flat origin/destination fields.
  4. Trip type return also stores a separate returnLegs array; the UI renders outbound and return chains separated by ·.
  5. Trip type multi_city simply uses legs with 3+ entries.

Legs are JSON, not a separate table

legs and returnLegs are stored as JSONB columns on AirlineQuotaBlock — there is no AirlineBlockLeg table. Validation and normalization happens in the service layer (see createAirlineBlock in src/services/airlineBlockService.ts).

PNR handling

PNR is stored at two levels and is treated as a required, uppercase, 8-character-ish identifier:

  • Block-level PNRAirlineQuotaBlock.pnr. Set on the create form (src/pages/inventory/AirlineBlocks.tsx:2194). The input handler uppercases on change:
    onChange={(e) => setBlockForm((p) => ({ ...p, pnr: e.target.value.toUpperCase() }))}
    
    When you save a block, its PNR is copied down into every leg object so each leg carries the same PNR by default (AirlineBlocks.tsx:1110):
    legs: outboundLegs.map((l) => ({ ...l, pnr: blockPnr })),
    returnLegs: returnLegs.map((l) => ({ ...l, pnr: blockPnr })),
    
  • Per-leg PNR — each leg object in legs / returnLegs can override the block PNR. The GroupFlight snapshot code (getGroupFlightSnapshot in src/lib/api.ts) resolves PNR with precedence leg.pnr || block.pnr || groupFlight.pnr.
  • Per-passenger PNRBookingPassenger.pnr can override for a specific passenger (added in 20260411000000_group_flights_multi.sql).
  • Return-leg PNR — when the return journey has its own PNR, AirlineQuotaBlock.returnPnr carries it.

maxLength 8 convention

Relationship to bookings and groups

A block reaches a booking through the GroupFlight join table (introduced in supabase/migrations/20260411000000_group_flights_multi.sql):

Booking ──(groupId)──▶ TravelGroup ──(GroupFlight)──▶ AirlineQuotaBlock
                              │                              │
                              │                              └─▶ B2BFlightOffer (partner resale)
                              └─▶ BookingPassenger.flightId ──┘ (per-pax flight pick)
  • The old direct TravelGroup.quotaBlockId FK was dropped in favour of GroupFlight, so a group can now hold any number of blocks and FIT entries simultaneously.
  • Each BookingPassenger may carry a flightId (pointing at a specific GroupFlight) and an override pnr, so a single booking can split passengers across different blocks.
  • BookingPassengerFlight (added in 20260418010000_passenger_flight_junction.sql) is the per-passenger junction mirroring BookingPassengerHotel.
  • Seat accounting: allocatedSeats goes up when a GroupFlight or B2BFlightOffer is created against a block; availableSeats = totalSeats - allocatedSeats.

Permissions

Route guard: inventory.view for all /inventory/** paths (src/App.tsx:150–157).

Action Permission Enforcement
View any /inventory/** page inventory.view <ProtectedRoute>
Create block / FIT / ground / B2B offer inventory.create <PermissionGate> + server requirePermission('inventory.create') on POST routes
Edit block / leg / seat count / sell seats inventory.edit <PermissionGate> + server requirePermission('inventory.edit') on PATCH routes
Delete block inventory.delete Seeded but not yet wired in UI — see docs/PERMISSIONS.md §8 drift item 6
Allocate block to group inventory.allocate Seeded; UI enforcement is a follow-up (drift item 6)

Supplier payments recorded on a block require finance.create. See docs/PERMISSIONS.md §6.12 for the authoritative row.

  • AirlineQuotaBlock — core table. Created in 20260113130121_remote_schema.sql. Trip-type + JSON legs added in 20260330120000_quota_block_legs.sql. Schedule fields in 20260118030000_add_quota_schedule.sql. Block codes (human-readable) in 20260401210000_airline_block_code.sql20260411080000_airline_block_code_v3.sql. Initial payment in 20260330130000_quota_block_initial_payment_amount.sql.
  • GroupFlight — group × block/FIT join; 20260411000000_group_flights_multi.sql.
  • B2BFlightOffer — partner resale of block seats; 20260119124500_b2b_flights.sql + 20260411040000_b2b_offer_cancellations.sql + 20260411050000_b2b_offer_seats_total_zero.sql.
  • FITInventory — individual-traveller flight records; 20260410130000_fit_inventory.sql.
  • Airline — airline master; seeded in 20260116100000_seed_airlines.sql and expanded in 20260118000000_seed_airlines_expanded.sql.
  • BookingPassengerFlight — per-passenger flight junction; 20260418010000_passenger_flight_junction.sql.