Files
edr-platform/apps/edr-passenger-web/backoffice/src/components/reports/FleetSeatOverview.tsx

470 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<SeatStatusOverview>({
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 (
<div className="card py-16 text-center text-muted-foreground">
<p className="text-sm">Loading fleet seat 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 seat 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">
<Armchair 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 seat status report</p>
</div>
);
}
const { window: win, totals } = data;
return (
<div className="space-y-6">
{/* 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. */}
<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 className="flex items-center gap-2 text-sm">
<TrendingUp className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Load factor</span>
<span className="font-semibold tabular-nums text-foreground">
{totals.loadFactorPercent}%
</span>
</div>
</div>
</div>
{/* Window totals. Distinct wording from the per-schedule summary cards so the two
are never mistaken for each other. */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{[
{ 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) => (
<div key={tile.label} className="card">
<div className="flex items-start gap-2">
{tile.slot !== -2 && (
<span
className="h-2.5 w-2.5 rounded-full mt-1.5 shrink-0"
style={{ backgroundColor: seriesColor(tile.slot) }}
aria-hidden
/>
)}
<div>
<p className="text-muted-foreground text-sm font-medium">{tile.label}</p>
<p className="text-2xl font-bold mt-1 tabular-nums text-foreground">
{tile.value}
</p>
<p className="text-xs text-muted-foreground mt-1">{tile.hint}</p>
</div>
</div>
</div>
))}
</div>
{/* Seat mix per departure day */}
<div className="card">
<div className="flex items-baseline justify-between gap-4 flex-wrap mb-1">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Seat mix by departure day
</h3>
<span className="text-xs text-muted-foreground">
{totals.sellableSeats} sellable seats in window
</span>
</div>
<p className="text-xs text-muted-foreground mb-4">
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&apos;s capacity.
</p>
{/* Legend carries visible text labels — the palette's light-mode contrast is
validated only with that relief in place. */}
<div className="flex items-center gap-4 flex-wrap mb-3">
{INVENTORY_SERIES.map((s) => (
<div key={s.key} className="flex items-center gap-1.5">
<span
className="h-2.5 w-2.5 rounded-sm"
style={{ backgroundColor: seriesColor(s.slot) }}
aria-hidden
/>
<span className="text-xs text-muted-foreground">{s.label}</span>
</div>
))}
</div>
<ResponsiveContainer width="100%" height={260}>
<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={{
background: palette.tooltipBg,
border: `1px solid ${palette.tooltipBorder}`,
borderRadius: 8,
fontSize: 12,
}}
labelStyle={{ color: palette.textMuted }}
/>
{INVENTORY_SERIES.map((s) => (
<Bar
key={s.key}
dataKey={s.key}
name={s.label}
stackId="seats"
fill={seriesColor(s.slot)}
radius={s.key === "available" ? [3, 3, 0, 0] : undefined}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
{/* Load factor per schedule — doubles as the picker */}
<div className="card">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-1">
Load factor by departure
</h3>
<p className="text-xs text-muted-foreground mb-4">
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&apos;s
report.
</p>
<ResponsiveContainer width="100%" height={44 * scheduleRows.length + 32}>
<BarChart
data={scheduleRows}
layout="vertical"
margin={{ top: 0, right: 40, bottom: 0, left: 8 }}
>
<CartesianGrid stroke={palette.grid} horizontal={false} />
<XAxis
type="number"
domain={[0, 100]}
unit="%"
tick={{ fill: palette.textMuted, fontSize: 12 }}
stroke={palette.axis}
tickLine={false}
axisLine={false}
/>
<YAxis
type="category"
dataKey="label"
width={130}
tick={{ fill: palette.textMuted, fontSize: 12 }}
stroke={palette.axis}
tickLine={false}
axisLine={false}
/>
<Tooltip
cursor={{ fill: palette.grid, fillOpacity: 0.35 }}
contentStyle={{
background: palette.tooltipBg,
border: `1px solid ${palette.tooltipBorder}`,
borderRadius: 8,
fontSize: 12,
}}
labelStyle={{ color: palette.textMuted }}
formatter={(value: number, _name, entry: any) => [
`${value}% · ${entry?.payload?.paid ?? 0} of ${entry?.payload?.sellableSeats ?? 0} seats`,
"Load factor",
]}
/>
<Bar
dataKey="loadFactorPercent"
name="Load factor"
fill={palette.sequential}
radius={[0, 3, 3, 0]}
cursor="pointer"
onClick={(entry: any) => {
const id = entry?.payload?.scheduleId ?? entry?.scheduleId;
if (id) onSelectSchedule(id);
}}
>
{scheduleRows.map((row) => (
<Cell key={row.scheduleId} fill={palette.sequential} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{/* 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. */}
<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 seat status 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",
"Sellable",
"Paid",
"Unpaid",
"Blocked",
"Available",
"Load",
].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.sellableSeats}
</td>
<td className="px-4 py-3 whitespace-nowrap tabular-nums">{s.paid}</td>
<td className="px-4 py-3 whitespace-nowrap tabular-nums">{s.unpaid}</td>
<td className="px-4 py-3 whitespace-nowrap tabular-nums">{s.blocked}</td>
<td className="px-4 py-3 whitespace-nowrap tabular-nums">
{s.available}
</td>
<td className="px-4 py-3 whitespace-nowrap tabular-nums">
{s.loadFactorPercent}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}