mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
470 lines
17 KiB
TypeScript
470 lines
17 KiB
TypeScript
"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'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'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'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>
|
||
);
|
||
}
|