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 adf6ccb9e..55f19dae0 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -37,6 +37,24 @@ export class ReportsController { return this.service.getPassengerList(scheduleId); } + // Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order. + @Get("passengers/overview") + @ApiOperation({ + summary: "Fleet-wide passenger mix across a departure window", + description: + "Landing view for the passengers report, shown before a schedule is picked. Returns passenger volume per " + + "departure day, nationality split, passenger-category mix and the busiest origin→destination pairs across " + + "the window, plus 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 CONFIRMED and BOARDED seats only, matching `GET /reports/passengers`. Carries no occupancy figure " + + "by design: this report and the seat status report measure capacity differently, so a shared occupancy " + + "number would contradict one of them.", + }) + getPassengerOverview(@Query('days') days?: string) { + return this.service.getPassengerOverview(days ? Number(days) : undefined); + } + @Get("passengers") @ApiOperation({ summary: "Passengers report for a specific schedule" }) getOccupancyReport(@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 7a4fde4cb..89a01dd0d 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -49,6 +49,11 @@ const OVERVIEW_ACTIVE_BOOKING_STATUSES: BookingStatus[] = [ 'PENDING_PAYMENT', ]; +/** The passengers report counts people, so a seat awaiting payment does not qualify. */ +const PASSENGER_ACTIVE_BOOKING_STATUSES: BookingStatus[] = ['CONFIRMED', 'BOARDED']; +/** Route pairs are long-tailed; only the busiest are legible in a chart. */ +const TOP_ROUTES_LIMIT = 8; + const EMPTY_OVERVIEW_TOTALS = { scheduleCount: 0, sellableSeats: 0, @@ -1109,6 +1114,260 @@ export class ReportsService { }; } + /** + * Fleet-wide passenger mix across a departure window — the landing view for the + * passengers report, shown before a schedule is picked. + * + * Answers "who travelled", not "how full were the trains". Occupancy is deliberately + * absent: this report and the seat status report count capacity differently (this one + * includes dining and placeholder seats in `totalSeats`, the other does not), so an + * occupancy figure here would either contradict the table below it or the seats page. + * That pre-existing difference is left alone rather than silently reconciled. + * + * Counts CONFIRMED and BOARDED only, matching {@link getOccupancyBySchedule} — a seat + * awaiting payment has no passenger on it yet. + */ + async getPassengerOverview(daysRaw?: number) { + const days = Math.min( + Math.max(Math.trunc(daysRaw || OVERVIEW_DEFAULT_DAYS), 1), + OVERVIEW_MAX_DAYS, + ); + const now = new Date(); + + // Window resolution is intentionally a copy of the one in getSeatStatusOverview + // rather than a shared helper: the two reports are free to diverge on what window + // makes sense for them, and a shared helper would couple them for ~20 lines. + 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, + originStationId: true, + destinationStationId: 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: { scheduleCount: 0, totalPassengers: 0, groupPassengers: 0 }, + byDay: [], + byNationality: [], + byCategory: [], + topRoutes: [], + schedules: [], + }; + } + + const scheduleIds = schedules.map((s) => s.id); + + // Same three-branch OR as the per-schedule report: own scheduleId, return leg, or a + // legacy null-scheduleId row reached through the booking's outbound schedule. + const bookingSeats = await this.prisma.bookingSeat.findMany({ + where: { + OR: [ + { + scheduleId: { in: scheduleIds }, + booking: { status: { in: PASSENGER_ACTIVE_BOOKING_STATUSES } }, + }, + { + leg: 2, + booking: { + returnScheduleId: { in: scheduleIds }, + status: { in: PASSENGER_ACTIVE_BOOKING_STATUSES }, + }, + }, + { + scheduleId: null, + leg: 1, + booking: { + scheduleId: { in: scheduleIds }, + status: { in: PASSENGER_ACTIVE_BOOKING_STATUSES }, + }, + }, + ], + }, + select: { + scheduleId: true, + leg: true, + bookingId: true, + passengerCategory: true, + passportCountry: true, + idDocumentType: true, + booking: { + select: { + scheduleId: true, + returnScheduleId: true, + originStationId: true, + destinationStationId: true, + }, + }, + }, + }); + + // Station names for the route pairs. Bookings that never recorded a station fall back + // to the schedule's own endpoints, the same fallback getOccupancyBySchedule applies. + const stationIds = [ + ...new Set( + [ + ...bookingSeats.flatMap((bs) => [ + bs.booking.originStationId, + bs.booking.destinationStationId, + ]), + ...schedules.flatMap((s) => [s.originStationId, s.destinationStationId]), + ].filter((id): id is string => Boolean(id)), + ), + ]; + const stations = stationIds.length + ? await this.prisma.station.findMany({ + where: { id: { in: stationIds } }, + select: { id: true, name: true }, + }) + : []; + const stationName = new Map(stations.map((s) => [s.id, s.name])); + + const scheduleById = new Map(schedules.map((s) => [s.id, s])); + const scheduleIdSet = new Set(scheduleIds); + + const passengersBySchedule = new Map(); + const nationalityCounts = new Map(); + const categoryCounts = new Map(); + const routeCounts = new Map(); + // A booking contributing more than one seat to the window is a group booking. + const seatsPerBooking = new Map(); + + for (const bs of bookingSeats) { + 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 schedule = scheduleById.get(scheduleId); + passengersBySchedule.set( + scheduleId, + (passengersBySchedule.get(scheduleId) ?? 0) + 1, + ); + seatsPerBooking.set(bs.bookingId, (seatsPerBooking.get(bs.bookingId) ?? 0) + 1); + + // Same derivation as getPassengerList, so the chart and the drill-down list agree + // on what a passenger's nationality is. + const nationality = bs.passportCountry + ? bs.passportCountry === 'Djibouti' + ? 'Djiboutian' + : bs.passportCountry + : bs.idDocumentType === 'NATIONAL_ID' + ? 'Ethiopian' + : 'Unknown'; + nationalityCounts.set(nationality, (nationalityCounts.get(nationality) ?? 0) + 1); + + const category = bs.passengerCategory ?? 'ADULT'; + categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1); + + const originId = bs.booking.originStationId ?? schedule?.originStationId ?? null; + const destinationId = + bs.booking.destinationStationId ?? schedule?.destinationStationId ?? null; + if (originId && destinationId) { + const key = `${originId}|${destinationId}`; + const existing = routeCounts.get(key); + if (existing) { + existing.passengers += 1; + } else { + routeCounts.set(key, { + origin: stationName.get(originId) ?? originId, + destination: stationName.get(destinationId) ?? destinationId, + passengers: 1, + }); + } + } + } + + const groupPassengers = [...seatsPerBooking.values()] + .filter((count) => count > 1) + .reduce((sum, count) => sum + count, 0); + + const scheduleRows = schedules.map((s) => ({ + scheduleId: s.id, + trainNumber: s.train.number, + originStation: s.originStation.name, + destinationStation: s.destinationStation.name, + departureAt: s.departureAt, + isPackage: s.isPackageOnly, + passengers: passengersBySchedule.get(s.id) ?? 0, + })); + + const byDayMap = new Map(); + for (const row of scheduleRows) { + const date = row.departureAt.toISOString().slice(0, 10); + const bucket = byDayMap.get(date) ?? { date, scheduleCount: 0, passengers: 0 }; + bucket.scheduleCount += 1; + bucket.passengers += row.passengers; + byDayMap.set(date, bucket); + } + + const rank = (rows: T[]) => + rows.sort((a, b) => b.passengers - a.passengers); + + return { + window: { + from, + to, + days, + direction, + truncated: schedules.length === OVERVIEW_MAX_SCHEDULES, + }, + totals: { + scheduleCount: scheduleRows.length, + totalPassengers: scheduleRows.reduce((sum, r) => sum + r.passengers, 0), + groupPassengers, + }, + byDay: [...byDayMap.values()].sort((a, b) => a.date.localeCompare(b.date)), + byNationality: rank( + [...nationalityCounts.entries()].map(([nationality, passengers]) => ({ + nationality, + passengers, + })), + ), + byCategory: rank( + [...categoryCounts.entries()].map(([category, passengers]) => ({ + category, + passengers, + })), + ), + topRoutes: rank([...routeCounts.values()]).slice(0, TOP_ROUTES_LIMIT), + schedules: scheduleRows, + }; + } + async getPaymentDiscrepancyReport(params: { from?: string; to?: string; diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx index ca6a2c2f8..0ed48c17b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Users, Armchair, BarChart3, Train, Download } from "lucide-react"; import { apiClient } from "@/lib/api-client"; +import FleetPassengerOverview from "@/components/reports/FleetPassengerOverview"; import { formatDateTime } from "@/lib/utils"; import ActionButton from "@/components/ui/ActionButton"; import Pagination from "@/components/ui/Pagination"; @@ -585,12 +586,9 @@ export default function PassengersReportPage() { )} - {!scheduleId && ( -
- -

Select a schedule above to load the occupancy report

-
- )} + {/* Landing state only. Unmounts the moment a schedule is selected, leaving the + per-schedule report above untouched. */} + {!scheduleId && } ); } diff --git a/apps/edr-passenger-web/backoffice/src/components/reports/FleetPassengerOverview.tsx b/apps/edr-passenger-web/backoffice/src/components/reports/FleetPassengerOverview.tsx new file mode 100644 index 000000000..c59121005 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/reports/FleetPassengerOverview.tsx @@ -0,0 +1,499 @@ +"use client"; + +/** + * Fleet passenger overview — the landing state of the Passengers Report, shown only + * while no schedule is selected. Once a schedule is picked this component unmounts and + * the per-schedule occupancy/list tabs take over unchanged. + * + * Carries no occupancy figure by design. This report and the seat status report measure + * capacity differently (this one counts dining and placeholder seats toward `totalSeats`, + * the other does not), so an occupancy number here would contradict one of them. This + * view answers "who travelled" and leaves "how full" to the seat status report. + */ + +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Bar, + BarChart, + CartesianGrid, + LabelList, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { CalendarClock, Users } 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; + passengers: number; +} + +interface PassengerOverview { + window: { + from: string; + to: string; + days: number; + direction: "UPCOMING" | "RECENT"; + truncated: boolean; + }; + totals: { + scheduleCount: number; + totalPassengers: number; + groupPassengers: number; + }; + byDay: { date: string; scheduleCount: number; passengers: number }[]; + byNationality: { nationality: string; passengers: number }[]; + byCategory: { category: string; passengers: number }[]; + topRoutes: { origin: string; destination: string; passengers: number }[]; + schedules: OverviewScheduleRow[]; +} + +/** + * Fixed colour domain for passenger category. Keyed by position in this list rather than + * by rank in the data, so a day with no children does not repaint the adult segment. + */ +const CATEGORY_ORDER = ["ADULT", "CHILD"] as const; +const CATEGORY_LABELS: Record = { ADULT: "Adult", CHILD: "Child" }; + +const MAX_NATIONALITY_BARS = 8; + +/** `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 FleetPassengerOverviewProps { + /** Selecting a schedule from a chart or row hands control to the drill-down. */ + onSelectSchedule: (scheduleId: string) => void; +} + +export default function FleetPassengerOverview({ + onSelectSchedule, +}: FleetPassengerOverviewProps) { + const isDark = useTheme((s) => s.isDark); + const palette = getChartPalette(isDark); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["passenger-overview"], + queryFn: () => apiClient.get("/reports/passengers/overview"), + }); + + const dayRows = useMemo( + () => (data?.byDay ?? []).map((d) => ({ ...d, label: formatDayLabel(d.date) })), + [data], + ); + + const nationalityRows = useMemo( + () => (data?.byNationality ?? []).slice(0, MAX_NATIONALITY_BARS), + [data], + ); + + const routeRows = useMemo( + () => + (data?.topRoutes ?? []).map((r) => ({ + ...r, + label: `${r.origin} → ${r.destination}`, + })), + [data], + ); + + // One row, one bar, stacked by category — a two-value composition reads better as a + // single bar than as a chart with two lonely columns. + const categoryRow = useMemo(() => { + const row: Record = { name: "mix" }; + for (const c of data?.byCategory ?? []) row[c.category] = c.passengers; + return [row]; + }, [data]); + + const categoriesPresent = useMemo(() => { + const seen = new Set((data?.byCategory ?? []).map((c) => c.category)); + const known = CATEGORY_ORDER.filter((c) => seen.has(c)); + const unknown = [...seen].filter( + (c) => !CATEGORY_ORDER.includes(c as (typeof CATEGORY_ORDER)[number]), + ); + return [...known, ...unknown]; + }, [data]); + + const categoryColor = (category: string) => { + const index = CATEGORY_ORDER.indexOf(category as (typeof CATEGORY_ORDER)[number]); + return categoricalColor(palette, index >= 0 ? index : CATEGORY_ORDER.length); + }; + + const tooltipStyle = { + background: palette.tooltipBg, + border: `1px solid ${palette.tooltipBorder}`, + borderRadius: 8, + fontSize: 12, + }; + + if (isLoading) { + return ( +
+

Loading fleet passenger overview…

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

Failed to load the fleet passenger 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 passengers report +

+
+ ); + } + + const { window: win, totals } = data; + const groupShare = + totals.totalPassengers > 0 + ? Math.round((totals.groupPassengers / totals.totalPassengers) * 100) + : 0; + + return ( +
+ {/* Window banner — the view 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"} +

+
+
+
+
+ + {/* Window totals */} +
+
+

Passengers

+

+ {totals.totalPassengers} +

+

Confirmed and boarded

+
+
+

Departures

+

+ {totals.scheduleCount} +

+

In this window

+
+
+

Travelling in groups

+

+ {totals.groupPassengers} +

+

+ {groupShare}% of passengers, on multi-seat bookings +

+
+
+ + {/* 1 — Passengers per departure day */} +
+

+ Passengers by departure day +

+

+ How many people travelled each day across every train in the window. +

+ + + + + + + + + +
+ +
+ {/* 2 — Nationality split */} +
+

+ Passengers by nationality +

+

+ Taken from passport country, or Ethiopian where a national ID was used. + “Unknown” means neither was recorded. +

+ + + + + + + + {/* Values printed on the bars — the palette's light-mode contrast is + validated only with numeric relief in place. */} + + + + +
+ + {/* 4 — Busiest origin → destination pairs */} +
+

+ Busiest routes +

+

+ Where people actually travelled from and to — not the train's own + endpoints, but each booking's. +

+ + + + + + + + + + + +
+
+ + {/* 3 — Passenger category mix */} +
+

+ Adult and child mix +

+

+ The whole bar is every passenger in the window, split by fare category. +

+ +
+ {categoriesPresent.map((category) => { + const count = + data.byCategory.find((c) => c.category === category)?.passengers ?? 0; + return ( +
+ + + {CATEGORY_LABELS[category] ?? category} · {count} + +
+ ); + })} +
+ + + + + + + {categoriesPresent.map((category) => ( + + ))} + + +
+ + {/* The same numbers as exact figures, and the picker */} +
+
+

+ Schedules in window +

+

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

+
+
+ + + + {["Departure", "Train", "Route", "Passengers"].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.passengers} +
+
+
+
+ ); +}