From aabf58320ac7ad02a95be2735d15f93d6ed15ca9 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 13 Aug 2026 15:32:56 +0300 Subject: [PATCH] feat: ( reports ) add fleet seat overview to seat status landing page --- .../src/modules/reports/reports.controller.ts | 17 + .../src/modules/reports/reports.service.ts | 381 ++++++++++++++ .../backoffice/src/app/reports/seats/page.tsx | 12 +- .../components/reports/FleetSeatOverview.tsx | 469 ++++++++++++++++++ 4 files changed, 872 insertions(+), 7 deletions(-) create mode 100644 apps/edr-passenger-web/backoffice/src/components/reports/FleetSeatOverview.tsx diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 1eea923fd..adf6ccb9e 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -60,6 +60,23 @@ export class ReportsController { return this.service.getSeatStatusReport(scheduleId); } + // Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order. + @Get("seat-status/overview") + @ApiOperation({ + summary: "Fleet-wide seat status across a departure window", + description: + "Landing view for the seat status report, shown before a schedule is picked. Returns the same four " + + "counters as the per-schedule report (paid, unpaid, expired holds, blocked) rolled up over a window of " + + "departures, plus per-day buckets and one row per schedule.\n\n" + + "The window is forward-looking — the next `days` days. If nothing is departing in that window, it falls " + + "back to the most recent `days` of departures on record and says so via `window.direction`.\n\n" + + "Counts apply the same rules as `GET /reports/seat-status`, so a schedule's row here equals what the " + + "drill-down shows after selecting it.", + }) + getSeatStatusOverview(@Query('days') days?: string) { + return this.service.getSeatStatusOverview(days ? Number(days) : undefined); + } + @Get("boarding") @ApiOperation({ summary: "Boarding report for a schedule — boarded vs not-boarded passengers" }) getBoardingReport(@Query('scheduleId') scheduleId: string) { diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index e899fdb4b..7a4fde4cb 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from "@nestjs/common"; import { InjectDataSource } from "@nestjs/typeorm"; import { DataSource } from "typeorm"; +import { BookingStatus } from "@prisma/client"; import { BlockedSeatRevenueLossReport, UNCATEGORIZED_REASON_CATEGORY, @@ -35,6 +36,43 @@ const FARE_QUOTE_CONCURRENCY = 4; /** CSV export is not paginated, but still needs an upper bound. */ const CSV_EXPORT_MAX_SCHEDULES = 5000; +// ── Fleet seat-status overview (landing view of the seat status report) ────── +const MS_PER_DAY_OVERVIEW = 24 * 60 * 60 * 1000; +const OVERVIEW_DEFAULT_DAYS = 7; +const OVERVIEW_MAX_DAYS = 31; +/** Upper bound on schedules charted at once. Signalled back as `window.truncated`. */ +const OVERVIEW_MAX_SCHEDULES = 60; +/** The booking statuses that put a seat on a schedule — same set as the drill-down. */ +const OVERVIEW_ACTIVE_BOOKING_STATUSES: BookingStatus[] = [ + 'CONFIRMED', + 'BOARDED', + 'PENDING_PAYMENT', +]; + +const EMPTY_OVERVIEW_TOTALS = { + scheduleCount: 0, + sellableSeats: 0, + paidCount: 0, + unpaidCount: 0, + expiredHoldCount: 0, + blockedCount: 0, + availableCount: 0, + loadFactorPercent: 0, +}; + +function emptyDayBucket(date: string) { + return { + date, + scheduleCount: 0, + sellableSeats: 0, + paid: 0, + unpaid: 0, + expiredHolds: 0, + blocked: 0, + available: 0, + }; +} + const EMPTY_LOSS_INPUT: LossCalculatorInput = { schedules: [], seatsById: new Map(), @@ -728,6 +766,349 @@ export class ReportsService { }; } + /** + * Fleet-wide seat status across a departure window — the landing view for the seat + * status report, shown before a schedule is picked. + * + * Deliberately a separate method from {@link getSeatStatusReport}: that one answers + * "this schedule, row by row" and its response shape is consumed by the drill-down UI. + * This one answers "the whole window, counts only". They share no code path, but they + * *do* share predicates — every filter below is the same rule the drill-down applies + * (the three-branch seat `OR`, the dining-coach exclusion, the counted-block + * resolution), so a schedule's row here always equals what you see after clicking it. + * Change one and the other must change with it. + */ + async getSeatStatusOverview(daysRaw?: number) { + const days = Math.min( + Math.max(Math.trunc(daysRaw || OVERVIEW_DEFAULT_DAYS), 1), + OVERVIEW_MAX_DAYS, + ); + const now = new Date(); + + // Forward-looking by default. But a database whose schedules are all in the past + // would render an empty chart, which reads as a broken page rather than an honest + // "nothing departing" — so fall back to the most recent window that has departures. + let from = now; + let to = new Date(now.getTime() + days * MS_PER_DAY_OVERVIEW); + let direction: 'UPCOMING' | 'RECENT' = 'UPCOMING'; + + const upcomingCount = await this.prisma.trainSchedule.count({ + where: { departureAt: { gte: from, lte: to }, status: { not: 'CANCELLED' } }, + }); + + if (upcomingCount === 0) { + const latest = await this.prisma.trainSchedule.findFirst({ + where: { departureAt: { lt: now }, status: { not: 'CANCELLED' } }, + orderBy: { departureAt: 'desc' }, + select: { departureAt: true }, + }); + if (latest) { + direction = 'RECENT'; + to = latest.departureAt; + from = new Date(to.getTime() - days * MS_PER_DAY_OVERVIEW); + } + } + + const schedules = await this.prisma.trainSchedule.findMany({ + where: { departureAt: { gte: from, lte: to }, status: { not: 'CANCELLED' } }, + select: { + id: true, + departureAt: true, + isPackageOnly: true, + train: { select: { number: true } }, + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + }, + orderBy: { departureAt: 'asc' }, + take: OVERVIEW_MAX_SCHEDULES, + }); + + if (schedules.length === 0) { + return { + window: { from, to, days, direction, truncated: false }, + totals: EMPTY_OVERVIEW_TOTALS, + byDay: [], + schedules: [], + }; + } + + const scheduleIds = schedules.map((s) => s.id); + const since24h = new Date(now.getTime() - MS_PER_DAY_OVERVIEW); + + const [assignments, bookingSeats, holds, blockRows] = await Promise.all([ + this.prisma.coachAssignment.findMany({ + where: { scheduleId: { in: scheduleIds } }, + select: { + scheduleId: true, + coachId: true, + coach: { + select: { + coachType: { select: { name: true, type: true } }, + seats: { select: { seatNumber: true } }, + }, + }, + }, + }), + // Same three-branch OR as the drill-down (`getSeatStatusReport`): a seat reaches a + // schedule by its own `scheduleId`, by being leg 2 of a return booking, or — on + // older rows with no `scheduleId` — by its booking's outbound schedule. A plain + // `groupBy scheduleId` would silently drop the last two. + this.prisma.bookingSeat.findMany({ + where: { + OR: [ + { + scheduleId: { in: scheduleIds }, + booking: { status: { in: OVERVIEW_ACTIVE_BOOKING_STATUSES } }, + }, + { + leg: 2, + booking: { + returnScheduleId: { in: scheduleIds }, + status: { in: OVERVIEW_ACTIVE_BOOKING_STATUSES }, + }, + }, + { + scheduleId: null, + leg: 1, + booking: { + scheduleId: { in: scheduleIds }, + status: { in: OVERVIEW_ACTIVE_BOOKING_STATUSES }, + }, + }, + ], + }, + select: { + scheduleId: true, + leg: true, + booking: { + select: { status: true, scheduleId: true, returnScheduleId: true }, + }, + seat: { + select: { + coach: { select: { coachType: { select: { name: true, type: true } } } }, + }, + }, + }, + }), + this.prisma.seatHold.findMany({ + where: { + scheduleId: { in: scheduleIds }, + expiresAt: { lt: now, gte: since24h }, + }, + select: { scheduleId: true }, + }), + this.prisma.seatBlock.findMany({ + where: { + OR: [{ scheduleId: { in: scheduleIds } }, { scheduleId: null }], + NOT: [ + { reason: { startsWith: 'MAINTENANCE:' } }, + { reason: { startsWith: TICKETING_BLOCK_REASON_PREFIX } }, + ], + }, + select: { + scheduleId: true, + blockedAt: true, + unblockAt: true, + seat: { + select: { + id: true, + coachId: true, + seatNumber: true, + coach: { select: { coachType: { select: { name: true, type: true } } } }, + }, + }, + }, + orderBy: { blockedAt: 'desc' }, + }), + ]); + + // ── Sellable seats and assigned coaches, per schedule ─────────────────────── + // Sellable = every seat on every assigned coach, minus dining coaches and + // placeholder rows — the same denominator the revenue-loss report uses. + const sellableBySchedule = new Map(); + const coachIdsBySchedule = new Map>(); + for (const assignment of assignments) { + const coachIds = + coachIdsBySchedule.get(assignment.scheduleId) ?? new Set(); + coachIds.add(assignment.coachId); + coachIdsBySchedule.set(assignment.scheduleId, coachIds); + + const coachType = assignment.coach?.coachType; + if ( + isDiningCoach({ + coachTypeType: coachType?.type ?? null, + coachTypeName: coachType?.name ?? null, + }) + ) { + continue; + } + const sellable = (assignment.coach?.seats ?? []).filter( + (seat) => !isPlaceholderSeat(seat), + ).length; + sellableBySchedule.set( + assignment.scheduleId, + (sellableBySchedule.get(assignment.scheduleId) ?? 0) + sellable, + ); + } + + // ── Paid / unpaid, per schedule ──────────────────────────────────────────── + const scheduleIdSet = new Set(scheduleIds); + const paidBySchedule = new Map(); + const unpaidBySchedule = new Map(); + for (const bs of bookingSeats) { + // Dining seats only — the drill-down does not drop placeholder rows from the + // passenger seat list, so neither does this. + const coachType = bs.seat?.coach?.coachType; + if ( + isDiningCoach({ + coachTypeType: coachType?.type ?? null, + coachTypeName: coachType?.name ?? null, + }) + ) { + continue; + } + + // Mirrors the OR branches, in the same order: an explicit `scheduleId` inside the + // window wins, then the return leg, then the booking's outbound schedule. + const scheduleId = + bs.scheduleId && scheduleIdSet.has(bs.scheduleId) + ? bs.scheduleId + : bs.leg === 2 + ? bs.booking.returnScheduleId + : bs.booking.scheduleId; + if (!scheduleId || !scheduleIdSet.has(scheduleId)) continue; + + const target = + bs.booking.status === 'PENDING_PAYMENT' ? unpaidBySchedule : paidBySchedule; + target.set(scheduleId, (target.get(scheduleId) ?? 0) + 1); + } + + // ── Expired holds, per schedule ──────────────────────────────────────────── + const holdsBySchedule = new Map(); + for (const hold of holds) { + holdsBySchedule.set( + hold.scheduleId, + (holdsBySchedule.get(hold.scheduleId) ?? 0) + 1, + ); + } + + // ── Blocked seats, per schedule ──────────────────────────────────────────── + // One counted block per seat, resolved exactly as the drill-down resolves it: a + // schedule-scoped block beats a global one, and rows arrive newest-first so the + // first of a kind seen for a seat is already the most recent. + const blockedBySchedule = new Map(); + for (const schedule of schedules) { + const assignedCoachIds = coachIdsBySchedule.get(schedule.id) ?? new Set(); + const countedSeatIds = new Map(); + + for (const block of blockRows) { + const seat = block.seat; + if (!seat || isPlaceholderSeat(seat)) continue; + + const coachType = seat.coach?.coachType; + if ( + isDiningCoach({ + coachTypeType: coachType?.type ?? null, + coachTypeName: coachType?.name ?? null, + }) + ) { + continue; + } + + if (block.scheduleId !== null) { + if (block.scheduleId !== schedule.id) continue; + } else { + if (!assignedCoachIds.has(seat.coachId)) continue; + if (!isGlobalBlockInEffectAt(block, schedule.departureAt)) continue; + } + + const existing = countedSeatIds.get(seat.id); + if (existing === undefined || (existing === null && block.scheduleId !== null)) { + countedSeatIds.set(seat.id, block.scheduleId); + } + } + + if (countedSeatIds.size > 0) { + blockedBySchedule.set(schedule.id, countedSeatIds.size); + } + } + + // ── Assemble ─────────────────────────────────────────────────────────────── + const scheduleRows = schedules.map((s) => { + const sellableSeats = sellableBySchedule.get(s.id) ?? 0; + const paid = paidBySchedule.get(s.id) ?? 0; + const unpaid = unpaidBySchedule.get(s.id) ?? 0; + const expiredHolds = holdsBySchedule.get(s.id) ?? 0; + const blocked = blockedBySchedule.get(s.id) ?? 0; + // Floored at zero: a seat can be both sold and blocked, so the parts can + // over-subtract. Never render a negative slice. + const available = Math.max(0, sellableSeats - paid - unpaid - blocked); + + return { + scheduleId: s.id, + trainNumber: s.train.number, + originStation: s.originStation.name, + destinationStation: s.destinationStation.name, + departureAt: s.departureAt, + isPackage: s.isPackageOnly, + sellableSeats, + paid, + unpaid, + expiredHolds, + blocked, + available, + loadFactorPercent: + sellableSeats > 0 ? +((paid / sellableSeats) * 100).toFixed(1) : 0, + }; + }); + + // Day buckets keyed on the UTC calendar date of departure, so the chart's axis and + // its bars are derived from one value and cannot disagree with each other. + const byDayMap = new Map>(); + for (const row of scheduleRows) { + const date = row.departureAt.toISOString().slice(0, 10); + const bucket = byDayMap.get(date) ?? emptyDayBucket(date); + bucket.scheduleCount += 1; + bucket.sellableSeats += row.sellableSeats; + bucket.paid += row.paid; + bucket.unpaid += row.unpaid; + bucket.expiredHolds += row.expiredHolds; + bucket.blocked += row.blocked; + bucket.available += row.available; + byDayMap.set(date, bucket); + } + const byDay = [...byDayMap.values()].sort((a, b) => a.date.localeCompare(b.date)); + + const sum = (pick: (r: (typeof scheduleRows)[number]) => number) => + scheduleRows.reduce((total, row) => total + pick(row), 0); + + const totalSellable = sum((r) => r.sellableSeats); + const totalPaid = sum((r) => r.paid); + + return { + window: { + from, + to, + days, + direction, + truncated: schedules.length === OVERVIEW_MAX_SCHEDULES, + }, + totals: { + scheduleCount: scheduleRows.length, + sellableSeats: totalSellable, + paidCount: totalPaid, + unpaidCount: sum((r) => r.unpaid), + expiredHoldCount: sum((r) => r.expiredHolds), + blockedCount: sum((r) => r.blocked), + availableCount: sum((r) => r.available), + loadFactorPercent: + totalSellable > 0 ? +((totalPaid / totalSellable) * 100).toFixed(1) : 0, + }, + byDay, + schedules: scheduleRows, + }; + } + async getPaymentDiscrepancyReport(params: { from?: string; to?: string; diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx index 2f5611f80..ef36ef741 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx @@ -2,8 +2,9 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { CheckCircle, Clock, AlertCircle, Ban, Armchair, Download } from "lucide-react"; +import { CheckCircle, Clock, AlertCircle, Ban, Download } from "lucide-react"; import { apiClient } from "@/lib/api-client"; +import FleetSeatOverview from "@/components/reports/FleetSeatOverview"; import Badge from "@/components/ui/Badge"; import ActionButton from "@/components/ui/ActionButton"; import { formatDateTime, formatCurrency } from "@/lib/utils"; @@ -165,12 +166,9 @@ export default function SeatStatusReportPage() { {isError &&

Failed to load report.

} - {!scheduleId && ( -
- -

Select a schedule above to load the seat status report

-
- )} + {/* Landing state only. Unmounts the moment a schedule is selected, leaving the + per-schedule report below untouched. */} + {!scheduleId && } {data && ( <> diff --git a/apps/edr-passenger-web/backoffice/src/components/reports/FleetSeatOverview.tsx b/apps/edr-passenger-web/backoffice/src/components/reports/FleetSeatOverview.tsx new file mode 100644 index 000000000..2febaa00b --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/reports/FleetSeatOverview.tsx @@ -0,0 +1,469 @@ +"use client"; + +/** + * Fleet seat overview — the landing state of the Seat Status Report, shown only while + * no schedule is selected. Once a schedule is picked this component unmounts and the + * per-schedule drill-down takes over unchanged. + * + * Its numbers come from `/reports/seat-status/overview`, which applies the same counting + * rules as `/reports/seat-status`, so a schedule's row here equals what the drill-down + * shows after clicking it. + */ + +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { Armchair, CalendarClock, TrendingUp } from "lucide-react"; +import { apiClient } from "@/lib/api-client"; +import { categoricalColor, getChartPalette } from "@/lib/chart-palette"; +import { useTheme } from "@/lib/theme-store"; + +interface OverviewScheduleRow { + scheduleId: string; + trainNumber: string; + originStation: string; + destinationStation: string; + departureAt: string; + isPackage: boolean; + sellableSeats: number; + paid: number; + unpaid: number; + expiredHolds: number; + blocked: number; + available: number; + loadFactorPercent: number; +} + +interface OverviewDayBucket { + date: string; + scheduleCount: number; + sellableSeats: number; + paid: number; + unpaid: number; + expiredHolds: number; + blocked: number; + available: number; +} + +interface SeatStatusOverview { + window: { + from: string; + to: string; + days: number; + direction: "UPCOMING" | "RECENT"; + truncated: boolean; + }; + totals: { + scheduleCount: number; + sellableSeats: number; + paidCount: number; + unpaidCount: number; + expiredHoldCount: number; + blockedCount: number; + availableCount: number; + loadFactorPercent: number; + }; + byDay: OverviewDayBucket[]; + schedules: OverviewScheduleRow[]; +} + +/** + * Fixed domain order for the inventory series, matching the left-to-right order of the + * drill-down's summary cards. Colour is keyed by position here and never by rank in the + * data, so a quiet day does not repaint the series. + * + * Expired holds are deliberately absent: a hold that has expired no longer occupies a + * seat, so stacking it against sellable capacity would double-count. It is reported as a + * standalone counter instead. + */ +const INVENTORY_SERIES = [ + { key: "paid", label: "Paid", slot: 0 }, + { key: "unpaid", label: "Unpaid", slot: 1 }, + { key: "blocked", label: "Blocked", slot: 3 }, + { key: "available", label: "Available", slot: -1 }, +] as const; + +const MAX_SCHEDULE_BARS = 12; + +/** `YYYY-MM-DD` → `05 Mar`, parsed by parts so no timezone can shift the label. */ +function formatDayLabel(date: string): string { + const [, month, day] = date.split("-"); + const monthName = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ][Number(month) - 1]; + return `${day} ${monthName}`; +} + +function formatWindow(from: string, to: string): string { + const opts: Intl.DateTimeFormatOptions = { day: "2-digit", month: "short" }; + return `${new Date(from).toLocaleDateString("en-GB", opts)} – ${new Date( + to, + ).toLocaleDateString("en-GB", opts)}`; +} + +export interface FleetSeatOverviewProps { + /** Selecting a schedule from a chart hands control to the drill-down. */ + onSelectSchedule: (scheduleId: string) => void; +} + +export default function FleetSeatOverview({ onSelectSchedule }: FleetSeatOverviewProps) { + const isDark = useTheme((s) => s.isDark); + const palette = getChartPalette(isDark); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["seat-status-overview"], + queryFn: () => apiClient.get("/reports/seat-status/overview"), + }); + + const seriesColor = (slot: number) => + slot < 0 ? palette.grid : categoricalColor(palette, slot); + + const dayRows = useMemo( + () => (data?.byDay ?? []).map((d) => ({ ...d, label: formatDayLabel(d.date) })), + [data], + ); + + // Busiest departures first — a 12-bar chart of the whole window would be unreadable, + // and the ones carrying the most seats are the ones worth looking at. + const scheduleRows = useMemo( + () => + (data?.schedules ?? []) + .slice() + .sort((a, b) => b.sellableSeats - a.sellableSeats) + .slice(0, MAX_SCHEDULE_BARS) + .map((s) => ({ + ...s, + label: `${s.trainNumber} · ${new Date(s.departureAt).toLocaleDateString("en-GB", { + day: "2-digit", + month: "short", + })}`, + })), + [data], + ); + + if (isLoading) { + return ( +
+

Loading fleet seat overview…

+
+ ); + } + + if (isError) { + return ( +
+

Failed to load the fleet seat overview.

+

+ Select a schedule above to load its report directly. +

+
+ ); + } + + if (!data || data.totals.scheduleCount === 0) { + return ( +
+ +

No departures on record to summarise

+

Select a schedule above to load its seat status report

+
+ ); + } + + const { window: win, totals } = data; + + return ( +
+ {/* Window banner — the report is fleet-wide until a schedule is chosen, and the + window may be historic, so both facts are stated rather than implied. */} +
+
+
+ +
+

+ All schedules · {formatWindow(win.from, win.to)} +

+

+ {win.direction === "UPCOMING" + ? `Next ${win.days} days — ${totals.scheduleCount} departure${totals.scheduleCount === 1 ? "" : "s"}` + : `No upcoming departures — showing the most recent ${win.days} days (${totals.scheduleCount} departure${totals.scheduleCount === 1 ? "" : "s"})`} + {win.truncated && " · truncated to the first 60 departures"} +

+
+
+
+ + Load factor + + {totals.loadFactorPercent}% + +
+
+
+ + {/* Window totals. Distinct wording from the per-schedule summary cards so the two + are never mistaken for each other. */} +
+ {[ + { label: "Paid", value: totals.paidCount, hint: "Payment confirmed", slot: 0 }, + { label: "Unpaid", value: totals.unpaidCount, hint: "Awaiting payment", slot: 1 }, + { label: "Blocked", value: totals.blockedCount, hint: "Withheld from sale", slot: 3 }, + { label: "Available", value: totals.availableCount, hint: "Still sellable", slot: -1 }, + { + label: "Expired Holds", + value: totals.expiredHoldCount, + hint: "Last 24h, seats released", + slot: -2, + }, + ].map((tile) => ( +
+
+ {tile.slot !== -2 && ( + + )} +
+

{tile.label}

+

+ {tile.value} +

+

{tile.hint}

+
+
+
+ ))} +
+ + {/* Seat mix per departure day */} +
+
+

+ Seat mix by departure day +

+ + {totals.sellableSeats} sellable seats in window + +
+

+ Every seat running on each day, split by what happened to it — paid, waiting on + payment, blocked, or still on sale. The whole bar is that day's capacity. +

+ + {/* Legend carries visible text labels — the palette's light-mode contrast is + validated only with that relief in place. */} +
+ {INVENTORY_SERIES.map((s) => ( +
+ + {s.label} +
+ ))} +
+ + + + + + + + {INVENTORY_SERIES.map((s) => ( + + ))} + + +
+ + {/* Load factor per schedule — doubles as the picker */} +
+

+ Load factor by departure +

+

+ How full each train is — paid seats as a share of the seats it can sell, so 100% + means sold out. Showing the {scheduleRows.length} busiest departure + {scheduleRows.length === 1 ? "" : "s"}; click a bar to open that train's + report. +

+ + + + + + + [ + `${value}% · ${entry?.payload?.paid ?? 0} of ${entry?.payload?.sellableSeats ?? 0} seats`, + "Load factor", + ]} + /> + { + const id = entry?.payload?.scheduleId ?? entry?.scheduleId; + if (id) onSelectSchedule(id); + }} + > + {scheduleRows.map((row) => ( + + ))} + + + +
+ + {/* The same numbers as a table — required relief for the palette's light-mode + contrast, and the only place the per-schedule detail is readable exactly. */} +
+
+

+ Schedules in window +

+

+ The exact numbers behind the charts, one row per departure. Click a row to + open that train's full seat status report. +

+
+
+ + + + {[ + "Departure", + "Train", + "Route", + "Sellable", + "Paid", + "Unpaid", + "Blocked", + "Available", + "Load", + ].map((h) => ( + + ))} + + + + {data.schedules.map((s) => ( + onSelectSchedule(s.scheduleId)} + className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer" + > + + + + + + + + + + + ))} + +
+ {h} +
+ {new Date(s.departureAt).toLocaleString("en-GB", { + dateStyle: "medium", + timeStyle: "short", + })} + + {s.trainNumber} + {s.isPackage && ( + (package) + )} + + {s.originStation} → {s.destinationStation} + + {s.sellableSeats} + {s.paid}{s.unpaid}{s.blocked} + {s.available} + + {s.loadFactorPercent}% +
+
+
+
+ ); +}