diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 0ce530d00..8d3b46228 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -87,6 +87,7 @@ import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; +import ScheduleCrewPage from "./pages/trainScheduling/ScheduleCrewPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; import TradeAccessPage from "./pages/configuration/TradeAccessPage"; import OperationsStandardsPage from "./pages/settings/OperationsStandardsPage"; @@ -900,6 +901,14 @@ const App = () => { } /> + + + + } + /> + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index e59851feb..314567b07 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -37,6 +37,7 @@ import { Send, Table2, Train, + Users, Weight, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; @@ -372,12 +373,26 @@ export default function TrainScheduleV2ListPage() { }, { id: "actions", - size: 32, + size: 210, meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` }, cell: ({ row }) => { const schedule = row.original; return ( - e.stopPropagation()}> + e.stopPropagation()}> + @@ -639,6 +654,9 @@ export default function TrainScheduleV2ListPage() { onTrack={() => navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`) } + onAssignCrew={() => + navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}/crew`) + } /> ))} @@ -1053,10 +1071,12 @@ function ScheduleCard({ schedule, onOpen, onTrack, + onAssignCrew, }: { schedule: TrainScheduleListItem; onOpen: () => void; onTrack: () => void; + onAssignCrew: () => void; }) { const { day, time } = splitDate(schedule.scheduleDate); const canTrack = ["DISPATCHED", "ARRIVED"].includes(schedule.status); @@ -1153,6 +1173,19 @@ function ScheduleCard({ Track ) : null} + diff --git a/apps/edr-freight-web/backoffice/src/pages/wagon-performance/WagonPerformancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/wagon-performance/WagonPerformancePage.tsx index bb7b7d163..1bce85e1e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/wagon-performance/WagonPerformancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/wagon-performance/WagonPerformancePage.tsx @@ -29,9 +29,14 @@ import { ArrowUp, ChartColumn, ChevronRight, + CircleCheck, List, MapPin, + PauseCircle, Search, + TrainFront, + Wrench, + type LucideIcon, } from "lucide-react"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; @@ -48,6 +53,17 @@ import { } from "./wagonPerformance"; import { downloadSheet, downloadSheets } from "./exportSection"; import { SectionExportButton } from "./SectionExportButton"; +import { + CardHeader, + ColumnChart, + LegendKey, + LegendRow, + SplitBar, + StackedBar, + formatCount, + toneVar, +} from "./chartKit"; +import "./wagonPerformance.css"; const WINDOWS = [ { value: "30", label: "30d" }, @@ -89,25 +105,78 @@ const IDLE_BUCKETS: Array<{ { label: "46 d +", min: 46, max: Infinity, tone: "red" }, ]; +/** + * One headline figure. + * + * The tone rail down the left edge is the tile's status channel — it repeats + * what the value's colour already says, so severity survives for a reader who + * cannot separate the hues. `meter` is an optional share of the fleet, drawn + * on a track one step lighter than its own fill so the whole bar reads. + */ const StatTile = ({ label, value, hint, color, + tone = "gray", + icon: Icon, + meter, }: { label: string; value: React.ReactNode; hint: string; color?: string; + tone?: string; + icon?: LucideIcon; + meter?: number; }) => ( - - - {label} - - + + + + + {label} + + {Icon ? : null} + + + {value} - + + {meter == null ? null : ( + + + + )} + + {hint} @@ -140,7 +209,13 @@ const SortHeader = ({ ); -/** A short ranked list — the "best / worst" boards. */ +/** + * A short ranked list — the "best / worst" boards. + * + * Each row carries a hairline bar scaled against the board's own leader, so + * the shape of the ranking (a runaway top wagon, or a flat field) is visible + * without reading every figure. Rows are buttons: they open the wagon. + */ const Leaderboard = ({ title, subtitle, @@ -151,69 +226,114 @@ const Leaderboard = ({ title: string; subtitle: string; accent: string; - rows: Array<{ id: string; number: string; note: string; value: string }>; + rows: Array<{ + id: string; + number: string; + note: string; + value: string; + weight?: number; + }>; onOpen: (id: string) => void; -}) => ( - - - - +}) => { + const peak = Math.max(1, ...rows.map((r) => r.weight ?? 0)); + + return ( + + + {title} - - - {subtitle} - - - {rows.length === 0 ? ( - - Nothing to rank yet. - - ) : ( - - {rows.map((r, i) => ( - onOpen(r.id)} - px="md" - py={9} - > - - - - {i + 1} + + {subtitle} + + + {rows.length === 0 ? ( + + Nothing to rank yet. + + ) : ( + + {rows.map((r, i) => ( + onOpen(r.id)} + px="md" + py={10} + className="wp-rank-row" + > + + + {/* Medallion: the top three carry the board's own tone. */} +
+ + {i + 1} + +
+
+ + {r.number} + + + {r.note} + +
+
+ + {r.value} -
- - {r.number} - - - {r.note} - -
- - {r.value} - -
-
- ))} -
- )} -
-); + {r.weight == null ? null : ( + + + + )} + + ))} + + )} +
+ ); +}; /** * Wagon performance — the executive report on how the wagon fleet is earning @@ -355,6 +475,9 @@ const WagonPerformancePage = () => { .sort((a, b) => b.wagons - a.wagons); }, [wagons]); + /** Busiest yard — the scale every yard's share bar is drawn against. */ + const yardPeak = Math.max(1, ...byYard.map((y) => y.wagons)); + /** Which classes of stock earn, and which sit. */ const byType = useMemo(() => { const rows = new Map< @@ -425,6 +548,7 @@ const WagonPerformancePage = () => { number: w.wagonNumber, note: `${w.wagonType?.code ?? "—"} · ${yardOf(w)}`, value: `${w.loadsInWindow ?? 0} loads`, + weight: w.loadsInWindow ?? 0, })), stranded: withIdle .sort((a, b) => b.idle - a.idle) @@ -434,6 +558,7 @@ const WagonPerformancePage = () => { number: w.wagonNumber, note: `${w.wagonType?.code ?? "—"} · ${yardOf(w)}`, value: `${idle} days`, + weight: idle, })), idle: [...wagons] .filter((w) => (w.movesInWindow ?? 0) === 0) @@ -721,12 +846,17 @@ const WagonPerformancePage = () => { 0 ? (kpis.inService / kpis.total) * 100 : 0} hint={ kpis.total > 0 ? `${Math.round((kpis.inService / kpis.total) * 100)}% of the fleet` @@ -735,20 +865,28 @@ const WagonPerformancePage = () => { /> 0 ? "red" : undefined} + tone={kpis.stranded > 0 ? "red" : "edr-slate"} + icon={PauseCircle} + meter={kpis.total > 0 ? (kpis.stranded / kpis.total) * 100 : 0} /> 0 ? "yellow.8" : undefined} + tone={kpis.offRoster > 0 ? "yellow" : "edr-slate"} + icon={Wrench} + meter={kpis.total > 0 ? (kpis.offRoster / kpis.total) * 100 : 0} /> @@ -776,136 +914,88 @@ const WagonPerformancePage = () => { {/* ── Status mix + idle distribution ───────────── */} - - - Status mix - - - - {kpis.total} wagons on the register - + + + } + /> {statusMix.length === 0 ? ( - + No wagons registered. ) : ( <> - + + ({ + key: s.status, + label: s.label, + value: s.count, + pct: s.pct, + tone: s.color, + }))} + /> + + {statusMix.map((s) => ( - ))} - - - {statusMix.map((s) => ( - - - - {s.label} - - - - {s.count} - - - {s.pct}% - - - - ))} )} - - - Idle-day distribution - - - - Wagons by days without movement in their current yard - - - {idleDistribution.map((b) => ( - - - {b.count} - - - - {b.label} - - - ))} + + + } + /> + {/* The bar colours are a severity scale, not identity, so + the key names the bands rather than each bucket. */} + + + + + - {kpis.stranded} wagons have sat over{" "} - {IDLE_THRESHOLD_DAYS} days + 0 ? "red" : undefined} + > + {kpis.stranded} + {" "} + wagons have sat over {IDLE_THRESHOLD_DAYS} days {kpis.total > 0 ? ` — ${Math.round((kpis.stranded / kpis.total) * 100)}% of the fleet locked up` : ""} @@ -947,29 +1037,30 @@ const WagonPerformancePage = () => { {/* ── By yard ──────────────────────────────────── */} - - - - By yard - - - - Where the fleet is parked and how long it stays - + + + + } + /> {byYard.length === 0 ? ( No wagons to group. ) : ( - + Yard + Share of fleet Wagons @@ -985,12 +1076,60 @@ const WagonPerformancePage = () => { {byYard.map((y) => ( - - {y.label} - + + + + {y.label} + + + + + {/* Bar is scaled against the busiest yard, so + the biggest one always fills the track. */} + 0 + ? Math.round( + (y.wagons / kpis.total) * 100, + ) + : 0 + }% of the fleet`} + > + + + + - + {y.wagons} @@ -1029,8 +1168,14 @@ const WagonPerformancePage = () => { {/* ── By wagon type ────────────────────────────── */} - - + +
By wagon type @@ -1038,33 +1183,9 @@ const WagonPerformancePage = () => { stuck
- - - - - Loaded - - - - - - Empty - - + + + { - + + + - - - - {t.loadedPct}% @@ -1491,7 +1614,15 @@ const WagonPerformancePage = () => { } w={72} /> - + {share}% diff --git a/apps/edr-freight-web/backoffice/src/pages/wagon-performance/chartKit.tsx b/apps/edr-freight-web/backoffice/src/pages/wagon-performance/chartKit.tsx new file mode 100644 index 000000000..7ff5aeafe --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/wagon-performance/chartKit.tsx @@ -0,0 +1,380 @@ +/** + * Presentation primitives for the wagon performance report. + * + * Pure display — every one of these takes numbers that are already derived + * and draws them. Kept apart from the page so the report's markup stays about + * what is being said, not about how a bar is rounded. + * + * House rules these encode (so charts across the report agree): + * · columns cap at 28px and never fill their slot — the leftover is air; + * · a data-end is rounded 4px, the baseline end stays square; + * · touching fills are separated by a 2px gap in the surface colour, never + * by a border — ink that is not data; + * · text wears text tokens; the colour lives on the mark beside it. + */ +import type { ReactNode } from "react"; +import { Box, Group, Stack, Text, Tooltip } from "@mantine/core"; + +import "./wagonPerformance.css"; + +/** + * Thousands-separated count. Fleet figures run into four digits, and `1284` + * is read as a code rather than a quantity. + */ +export const formatCount = (n: number): string => n.toLocaleString("en-US"); + +/** One-step-off-surface hairline, for gridlines and baselines. */ +export const GRID_LINE = "var(--mantine-color-edr-divider-0)"; + +/** Resolve a Mantine colour name (`edr-green`, `red.6`) to a CSS variable. */ +export const toneVar = (tone: string, fallbackShade = 6): string => { + const [name, shade] = tone.split("."); + return `var(--mantine-color-${name}-${shade ?? fallbackShade})`; +}; + +/* ────────────────────────────────────────────────────────────────────────── */ + +export interface SparkBarDatum { + label: string; + count: number; + /** Bar height as a share of the tallest bar, 0–100. */ + pct: number; + tone: string; +} + +/** + * Column chart for a bucketed distribution. + * + * Bars sit on a real baseline with three recessive gridlines behind them, so + * a reader can judge a middle bar against a neighbour instead of guessing. + * Only the tallest column keeps a permanent value label; the rest carry theirs + * in the hover tooltip, because a number over every column stops being read. + */ +export const ColumnChart = ({ + data, + height = 190, + unit = "wagons", +}: { + data: SparkBarDatum[]; + height?: number; + unit?: string; +}) => { + const peak = Math.max(...data.map((d) => d.count), 0); + + return ( + + + {/* Gridlines at the peak and two even steps below it, behind the + bars. The peak line doubles as the chart's top edge, so the + tallest column reads as touching it rather than floating. */} + {[0, 1, 2].map((i) => ( + + ))} + + {data.map((d) => { + const isPeak = d.count === peak && peak > 0; + return ( + + + {/* The label is absolutely positioned above its bar so it + never eats the bar's own height — otherwise the tallest + column can never reach the peak gridline. */} + + {isPeak ? ( + + {d.count} + + ) : null} + + + + ); + })} + + + + {/* Baseline: one weight heavier than the gridlines, so zero reads. */} + + + + {data.map((d) => ( + + {d.label} + + ))} + + + ); +}; + +/* ────────────────────────────────────────────────────────────────────────── */ + +export interface StackSegment { + key: string; + label: string; + value: number; + /** Segment width as a share of the whole, 0–100. */ + pct: number; + tone: string; +} + +/** + * A single stacked proportion bar. + * + * Segments are separated by a 2px gap in the surface colour rather than a + * stroke, so neighbouring shades stay distinct without extra ink. Every + * segment is hoverable; none is labelled inline, since interior segments have + * no free end to label without clipping. + */ +export const StackedBar = ({ + segments, + height = 12, + unit = "", +}: { + segments: StackSegment[]; + height?: number; + unit?: string; +}) => ( + + {segments + .filter((s) => s.value > 0) + .map((s, i, shown) => ( + + + + ))} + +); + +/* ────────────────────────────────────────────────────────────────────────── */ + +/** + * Two-tone split bar for a loaded / empty style mix, sized inside a table row. + * The unfilled remainder is a lighter step of the same ramp, so the whole + * track carries state rather than only the filled part. + */ +export const SplitBar = ({ + primaryPct, + secondaryPct, + primaryTone = "edr-green", + secondaryTone = "teal.3", + primaryLabel, + secondaryLabel, +}: { + primaryPct: number; + secondaryPct: number; + primaryTone?: string; + secondaryTone?: string; + primaryLabel: string; + secondaryLabel: string; +}) => { + const both = primaryPct > 0 && secondaryPct > 0; + // A zero-value side is dropped entirely rather than shown as a sliver — + // a 1px nub of the wrong colour on a 100% bar reads as bad data. + return ( + + {primaryPct > 0 ? ( + + + + ) : null} + {secondaryPct > 0 ? ( + + + + ) : null} + {/* Nothing moved at all — an empty track, so the row still has a shape. */} + {primaryPct === 0 && secondaryPct === 0 ? ( + + ) : null} + + ); +}; + +/* ────────────────────────────────────────────────────────────────────────── */ + +/** Legend swatch + label + value, the identity channel beside every chart. */ +export const LegendRow = ({ + tone, + label, + value, + pct, +}: { + tone: string; + label: string; + value: ReactNode; + pct?: number; +}) => ( + + + + + {label} + + + + + {value} + + {pct == null ? null : ( + + {pct}% + + )} + + +); + +/** Small square colour key used in a card header's inline legend. */ +export const LegendKey = ({ tone, label }: { tone: string; label: string }) => ( + + + + {label} + + +); + +/** A card's title block: name, one line of context, and its own actions. */ +export const CardHeader = ({ + title, + subtitle, + action, +}: { + title: string; + subtitle?: string; + action?: ReactNode; +}) => ( + +
+ {title} + {subtitle ? ( + + {subtitle} + + ) : null} +
+ {action} +
+); diff --git a/apps/edr-freight-web/backoffice/src/pages/wagon-performance/wagonPerformance.css b/apps/edr-freight-web/backoffice/src/pages/wagon-performance/wagonPerformance.css new file mode 100644 index 000000000..e47c99edb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/wagon-performance/wagonPerformance.css @@ -0,0 +1,46 @@ +/* ============================================================ + Wagon performance report — hover affordances. + + Only the states Mantine props cannot express live here. Everything + structural stays in the components; this file is purely "what changes + under the pointer". + ============================================================ */ + +/* Leaderboard rows are buttons that open a wagon — they need to say so. */ +.wp-rank-row { + border-radius: 8px; + transition: + background-color 120ms ease, + transform 120ms ease; +} +.wp-rank-row:hover { + background: var(--mantine-color-edr-slate-soft-0); +} +.wp-rank-row:active { + transform: scale(0.995); +} +.wp-rank-row:focus-visible { + outline: 2px solid var(--mantine-color-edr-green-5); + outline-offset: -2px; +} + +/* Cards lift very slightly on hover — enough to read as a surface, not + enough to make a still page feel restless. */ +.wp-stat-tile { + transition: + box-shadow 140ms ease, + border-color 140ms ease; +} +.wp-stat-tile:hover { + border-color: var(--mantine-color-edr-border-0); + box-shadow: 0 4px 14px rgba(16, 24, 40, 0.07); +} + +/* Bars dim their neighbours on hover so the hovered one reads as selected. */ +.wp-col-chart:hover .wp-col-bar { + opacity: 0.45; +} +.wp-col-chart .wp-col-bar:hover, +.wp-col-chart:hover .wp-col-slot:hover .wp-col-bar { + opacity: 1; +}