feat: ( reports ) add fleet passenger overview to passengers landing page

This commit is contained in:
Abubeker Yasin
2026-08-13 16:20:21 +03:00
parent aabf58320a
commit 62b2080aaf
4 changed files with 780 additions and 6 deletions

View File

@@ -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>
);
}

View File

@@ -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.
&ldquo;Unknown&rdquo; 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&apos;s own
endpoints, but each booking&apos;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&apos;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>
);
}