mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1277 from Tria-plc/alpha
feat: ( reports ) add fleet passenger overview to passengers landing …
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -51,6 +51,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,
|
||||
@@ -1138,6 +1143,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<string, number>();
|
||||
const nationalityCounts = new Map<string, number>();
|
||||
const categoryCounts = new Map<string, number>();
|
||||
const routeCounts = new Map<string, { origin: string; destination: string; passengers: number }>();
|
||||
// A booking contributing more than one seat to the window is a group booking.
|
||||
const seatsPerBooking = new Map<string, number>();
|
||||
|
||||
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<string, { date: string; scheduleCount: number; passengers: number }>();
|
||||
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 = <T extends { passengers: number }>(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;
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!scheduleId && (
|
||||
<div className="card py-16 text-center text-muted-foreground">
|
||||
<Armchair className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>Select a schedule above to load the occupancy report</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Landing state only. Unmounts the moment a schedule is selected, leaving the
|
||||
per-schedule report above untouched. */}
|
||||
{!scheduleId && <FleetPassengerOverview onSelectSchedule={setScheduleId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, string> = { 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<PassengerOverview>({
|
||||
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<string, number | string> = { 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 (
|
||||
<div className="card py-16 text-center text-muted-foreground">
|
||||
<p className="text-sm">Loading fleet passenger overview…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="card py-12 text-center">
|
||||
<p className="text-sm text-red-500">Failed to load the fleet passenger overview.</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Select a schedule above to load its report directly.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.totals.scheduleCount === 0) {
|
||||
return (
|
||||
<div className="card py-16 text-center text-muted-foreground">
|
||||
<Users className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>No departures on record to summarise</p>
|
||||
<p className="text-xs mt-1">
|
||||
Select a schedule above to load its passengers report
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { window: win, totals } = data;
|
||||
const groupShare =
|
||||
totals.totalPassengers > 0
|
||||
? Math.round((totals.groupPassengers / totals.totalPassengers) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 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. */}
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-start gap-3">
|
||||
<CalendarClock className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
All schedules · {formatWindow(win.from, win.to)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{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"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Window totals */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="card">
|
||||
<p className="text-muted-foreground text-sm font-medium">Passengers</p>
|
||||
<p className="text-2xl font-bold mt-1 tabular-nums text-foreground">
|
||||
{totals.totalPassengers}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Confirmed and boarded</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<p className="text-muted-foreground text-sm font-medium">Departures</p>
|
||||
<p className="text-2xl font-bold mt-1 tabular-nums text-foreground">
|
||||
{totals.scheduleCount}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">In this window</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<p className="text-muted-foreground text-sm font-medium">Travelling in groups</p>
|
||||
<p className="text-2xl font-bold mt-1 tabular-nums text-foreground">
|
||||
{totals.groupPassengers}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{groupShare}% of passengers, on multi-seat bookings
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 1 — Passengers per departure day */}
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-1">
|
||||
Passengers by departure day
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
How many people travelled each day across every train in the window.
|
||||
</p>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={dayRows} margin={{ top: 4, right: 8, bottom: 4, left: 0 }}>
|
||||
<CartesianGrid stroke={palette.grid} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: palette.textMuted, fontSize: 12 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: palette.textMuted, fontSize: 12 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: palette.grid, fillOpacity: 0.35 }}
|
||||
contentStyle={tooltipStyle}
|
||||
labelStyle={{ color: palette.textMuted }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="passengers"
|
||||
name="Passengers"
|
||||
fill={palette.sequential}
|
||||
radius={[3, 3, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 2 — Nationality split */}
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-1">
|
||||
Passengers by nationality
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Taken from passport country, or Ethiopian where a national ID was used.
|
||||
“Unknown” means neither was recorded.
|
||||
</p>
|
||||
<ResponsiveContainer width="100%" height={38 * nationalityRows.length + 32}>
|
||||
<BarChart
|
||||
data={nationalityRows}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 44, bottom: 0, left: 8 }}
|
||||
>
|
||||
<CartesianGrid stroke={palette.grid} horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fill: palette.textMuted, fontSize: 12 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="nationality"
|
||||
width={110}
|
||||
tick={{ fill: palette.textMuted, fontSize: 12 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: palette.grid, fillOpacity: 0.35 }}
|
||||
contentStyle={tooltipStyle}
|
||||
labelStyle={{ color: palette.textMuted }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="passengers"
|
||||
name="Passengers"
|
||||
fill={palette.sequential}
|
||||
radius={[0, 3, 3, 0]}
|
||||
>
|
||||
{/* Values printed on the bars — the palette's light-mode contrast is
|
||||
validated only with numeric relief in place. */}
|
||||
<LabelList
|
||||
dataKey="passengers"
|
||||
position="right"
|
||||
fill={palette.textMuted}
|
||||
fontSize={11}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* 4 — Busiest origin → destination pairs */}
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-1">
|
||||
Busiest routes
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Where people actually travelled from and to — not the train's own
|
||||
endpoints, but each booking's.
|
||||
</p>
|
||||
<ResponsiveContainer width="100%" height={38 * routeRows.length + 32}>
|
||||
<BarChart
|
||||
data={routeRows}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 44, bottom: 0, left: 8 }}
|
||||
>
|
||||
<CartesianGrid stroke={palette.grid} horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fill: palette.textMuted, fontSize: 12 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
width={170}
|
||||
tick={{ fill: palette.textMuted, fontSize: 11 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: palette.grid, fillOpacity: 0.35 }}
|
||||
contentStyle={tooltipStyle}
|
||||
labelStyle={{ color: palette.textMuted }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="passengers"
|
||||
name="Passengers"
|
||||
fill={palette.sequential}
|
||||
radius={[0, 3, 3, 0]}
|
||||
>
|
||||
<LabelList
|
||||
dataKey="passengers"
|
||||
position="right"
|
||||
fill={palette.textMuted}
|
||||
fontSize={11}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3 — Passenger category mix */}
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-1">
|
||||
Adult and child mix
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
The whole bar is every passenger in the window, split by fare category.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 flex-wrap mb-3">
|
||||
{categoriesPresent.map((category) => {
|
||||
const count =
|
||||
data.byCategory.find((c) => c.category === category)?.passengers ?? 0;
|
||||
return (
|
||||
<div key={category} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-sm"
|
||||
style={{ backgroundColor: categoryColor(category) }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{CATEGORY_LABELS[category] ?? category} · {count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height={64}>
|
||||
<BarChart
|
||||
data={categoryRow}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
|
||||
>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis type="category" dataKey="name" hide />
|
||||
<Tooltip contentStyle={tooltipStyle} labelStyle={{ display: "none" }} />
|
||||
{categoriesPresent.map((category) => (
|
||||
<Bar
|
||||
key={category}
|
||||
dataKey={category}
|
||||
name={CATEGORY_LABELS[category] ?? category}
|
||||
stackId="mix"
|
||||
fill={categoryColor(category)}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* The same numbers as exact figures, and the picker */}
|
||||
<div className="card p-0">
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Schedules in window
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
The exact numbers behind the charts, one row per departure. Click a row to
|
||||
open that train's full passengers report.
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{["Departure", "Train", "Route", "Passengers"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 whitespace-nowrap"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{data.schedules.map((s) => (
|
||||
<tr
|
||||
key={s.scheduleId}
|
||||
onClick={() => onSelectSchedule(s.scheduleId)}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
|
||||
>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">
|
||||
{new Date(s.departureAt).toLocaleString("en-GB", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap font-medium">
|
||||
{s.trainNumber}
|
||||
{s.isPackage && (
|
||||
<span className="text-muted-foreground text-xs"> (package)</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs">
|
||||
{s.originStation} → {s.destinationStation}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap tabular-nums">
|
||||
{s.passengers}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user