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.legsandAirlineQuotaBlock.returnLegs(added insupabase/migrations/20260330120000_quota_block_legs.sql). Each leg carriesorigin,destination,terminal,arrivalTerminal,flightNo,departDate,departTime,arriveDate,arriveTime, and an optional per-legpnr. - B2B seat sales — the page includes a "Sell Seats" flow that creates
B2BFlightOfferrows against a block, loweringavailableSeatsand creating an invoice on the partner ledger. - Supplier payments / finance events — recorded against the airline supplier via
postAirlineBlockFinanceEventinsrc/services/airlineBlockService.ts. - FIT inventory — the related FIT (Free Individual Traveller) table lives on a sibling page at
src/pages/inventory/FITInventory.tsxfor 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 viaautoOpenBlockId.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:
- Start with the first leg's
origin, then append every leg'sdestinationin order. - Result is a
A → B → C → Dchain (e.g.BLR → DOH → JEDfor a BLR→JED with a DOH stopover). - If
legsis empty/missing, fall back to the block's flatorigin/destinationfields. - Trip type
returnalso stores a separatereturnLegsarray; the UI renders outbound and return chains separated by·. - Trip type
multi_citysimply useslegswith 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 PNR —
AirlineQuotaBlock.pnr. Set on the create form (src/pages/inventory/AirlineBlocks.tsx:2194). The input handler uppercases on change: 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): - Per-leg PNR — each leg object in
legs/returnLegscan override the block PNR. The GroupFlight snapshot code (getGroupFlightSnapshotinsrc/lib/api.ts) resolves PNR with precedenceleg.pnr || block.pnr || groupFlight.pnr. - Per-passenger PNR —
BookingPassenger.pnrcan override for a specific passenger (added in20260411000000_group_flights_multi.sql). - Return-leg PNR — when the return journey has its own PNR,
AirlineQuotaBlock.returnPnrcarries 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.quotaBlockIdFK was dropped in favour ofGroupFlight, so a group can now hold any number of blocks and FIT entries simultaneously. - Each
BookingPassengermay carry aflightId(pointing at a specificGroupFlight) and an overridepnr, so a single booking can split passengers across different blocks. BookingPassengerFlight(added in20260418010000_passenger_flight_junction.sql) is the per-passenger junction mirroringBookingPassengerHotel.- Seat accounting:
allocatedSeatsgoes up when aGroupFlightorB2BFlightOfferis 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.
Related tables (Supabase)
AirlineQuotaBlock— core table. Created in20260113130121_remote_schema.sql. Trip-type + JSON legs added in20260330120000_quota_block_legs.sql. Schedule fields in20260118030000_add_quota_schedule.sql. Block codes (human-readable) in20260401210000_airline_block_code.sql→20260411080000_airline_block_code_v3.sql. Initial payment in20260330130000_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 in20260116100000_seed_airlines.sqland expanded in20260118000000_seed_airlines_expanded.sql.BookingPassengerFlight— per-passenger flight junction;20260418010000_passenger_flight_junction.sql.