feat: add ScheduleCrewPage and integrate crew assignment functionality

- Introduced ScheduleCrewPage for assigning train crew to schedules.
- Updated routing in App.tsx to include crew assignment path.
- Enhanced TrainScheduleV2ListPage with a button to navigate to crew assignment.
- Added new chart components and styles for improved wagon performance reporting.
- Refactored existing styles and components for better visual feedback and usability.
This commit is contained in:
marshalyordanos
2026-09-05 08:36:36 +03:00
parent 8437a6d5f1
commit 63ccb060c6
6 changed files with 861 additions and 239 deletions

View File

@@ -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 = () => {
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId/crew"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<ScheduleCrewPage />
</RequirePermission>
}
/>
<Route
path="routes"
element={

View File

@@ -0,0 +1,23 @@
import { useParams } from "react-router-dom";
import { PageContainer, PageHeader } from "@/components/page";
/**
* Train crew assignment for one schedule.
*
* Intentionally blank: the assignment rules — crew counts per role, the driver
* pairing cases, and which corridor segment each driver covers — are still to
* be specified, so only the route and header exist so far.
*/
export default function ScheduleCrewPage() {
const { scheduleId = "" } = useParams();
return (
<PageContainer>
<PageHeader
title="Assign Train Crew"
backTo={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
/>
</PageContainer>
);
}

View File

@@ -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 (
<Group justify="flex-end" onClick={(e) => e.stopPropagation()}>
<Group gap="xs" justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
<Button
variant="light"
color="indigo"
size="xs"
radius="md"
leftSection={<Users size={14} />}
onClick={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${schedule.id}/crew`,
)
}
>
Assign Train Crew
</Button>
<Menu position="bottom-end" withinPortal shadow="md" width={190}>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="Row actions">
@@ -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`)
}
/>
))}
</SimpleGrid>
@@ -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
</Button>
) : null}
<Button
variant="light"
color="indigo"
size="sm"
radius="md"
leftSection={<Users size={15} />}
onClick={(e) => {
e.stopPropagation();
onAssignCrew();
}}
>
Assign Train Crew
</Button>
</Group>
</Stack>
</Card>

View File

@@ -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;
}) => (
<Card withBorder radius="md" padding="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
<Text size="26px" fw={700} lh={1.1} mt={8} c={color}>
<Card
withBorder
radius="lg"
padding="md"
pl="lg"
className="wp-stat-tile"
style={{ position: "relative", overflow: "hidden" }}
>
<Box
style={{
position: "absolute",
insetBlock: 0,
left: 0,
width: 3,
background: toneVar(tone),
}}
/>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Text size="xs" c="dimmed" tt="uppercase" fw={600} lh={1.3}>
{label}
</Text>
{Icon ? <Icon size={15} strokeWidth={2} color={toneVar(tone)} /> : null}
</Group>
<Text size="30px" fw={700} lh={1.05} mt={10} c={color}>
{value}
</Text>
<Text size="xs" c="dimmed" mt={6} lh={1.35}>
{meter == null ? null : (
<Box
mt={12}
h={4}
style={{
borderRadius: 999,
background: toneVar(tone, 1),
overflow: "hidden",
}}
>
<Box
h="100%"
w={`${Math.min(100, Math.max(0, meter))}%`}
style={{ borderRadius: 999, background: toneVar(tone) }}
/>
</Box>
)}
<Text size="xs" c="dimmed" mt={meter == null ? 8 : 8} lh={1.35}>
{hint}
</Text>
</Card>
@@ -140,7 +209,13 @@ const SortHeader = ({
</UnstyledButton>
);
/** 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;
}) => (
<Card withBorder radius="md" padding={0}>
<Box
p="md"
pb="sm"
style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap={8} wrap="nowrap">
<Box
w={7}
h={7}
style={{
borderRadius: 2,
background: `var(--mantine-color-${accent}-6)`,
}}
/>
}) => {
const peak = Math.max(1, ...rows.map((r) => r.weight ?? 0));
return (
<Card withBorder radius="lg" padding={0} style={{ overflow: "hidden" }}>
<Box style={{ height: 3, background: toneVar(accent) }} />
<Box
p="md"
pb="sm"
style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Text fw={600} size="sm">
{title}
</Text>
</Group>
<Text size="xs" c="dimmed" mt={4}>
{subtitle}
</Text>
</Box>
{rows.length === 0 ? (
<Text size="sm" c="dimmed" py="lg" ta="center">
Nothing to rank yet.
</Text>
) : (
<Stack gap={0} py={4}>
{rows.map((r, i) => (
<UnstyledButton
key={r.id}
onClick={() => onOpen(r.id)}
px="md"
py={9}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="xs" fw={600} c="dimmed" w={14}>
{i + 1}
<Text size="xs" c="dimmed" mt={4}>
{subtitle}
</Text>
</Box>
{rows.length === 0 ? (
<Text size="sm" c="dimmed" py="xl" ta="center">
Nothing to rank yet.
</Text>
) : (
<Stack gap={0} py={4}>
{rows.map((r, i) => (
<UnstyledButton
key={r.id}
onClick={() => onOpen(r.id)}
px="md"
py={10}
className="wp-rank-row"
>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
{/* Medallion: the top three carry the board's own tone. */}
<Center
w={20}
h={20}
style={{
flexShrink: 0,
borderRadius: 6,
background:
i < 3
? toneVar(accent, 0)
: "var(--mantine-color-edr-slate-soft-0)",
}}
>
<Text
size="10px"
fw={700}
c={i < 3 ? `${accent}.8` : "dimmed"}
lh={1}
>
{i + 1}
</Text>
</Center>
<div style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{r.number}
</Text>
<Text size="xs" c="dimmed" truncate>
{r.note}
</Text>
</div>
</Group>
<Text
size="sm"
fw={700}
style={{
whiteSpace: "nowrap",
fontVariantNumeric: "tabular-nums",
}}
>
{r.value}
</Text>
<div style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{r.number}
</Text>
<Text size="xs" c="dimmed" truncate>
{r.note}
</Text>
</div>
</Group>
<Text size="sm" fw={700} style={{ whiteSpace: "nowrap" }}>
{r.value}
</Text>
</Group>
</UnstyledButton>
))}
</Stack>
)}
</Card>
);
{r.weight == null ? null : (
<Box
mt={8}
ml={30}
h={3}
style={{
borderRadius: 999,
background: "var(--mantine-color-edr-divider-0)",
}}
>
<Box
h="100%"
w={`${Math.max(2, (r.weight / peak) * 100)}%`}
style={{ borderRadius: 999, background: toneVar(accent) }}
/>
</Box>
)}
</UnstyledButton>
))}
</Stack>
)}
</Card>
);
};
/**
* 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 = () => {
<SimpleGrid cols={{ base: 1, sm: 2, lg: 5 }} spacing="md">
<StatTile
label="Fleet size"
value={kpis.total}
value={formatCount(kpis.total)}
hint="Wagons on the register"
tone="edr-slate"
icon={TrainFront}
/>
<StatTile
label="In service"
value={kpis.inService}
value={formatCount(kpis.inService)}
tone="edr-green"
icon={CircleCheck}
meter={kpis.total > 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 = () => {
/>
<StatTile
label={`Idle over ${IDLE_THRESHOLD_DAYS}d`}
value={kpis.stranded}
value={formatCount(kpis.stranded)}
hint="No movement in the current yard"
color={kpis.stranded > 0 ? "red" : undefined}
tone={kpis.stranded > 0 ? "red" : "edr-slate"}
icon={PauseCircle}
meter={kpis.total > 0 ? (kpis.stranded / kpis.total) * 100 : 0}
/>
<StatTile
label="Off roster"
value={kpis.offRoster}
value={formatCount(kpis.offRoster)}
hint="Maintenance, detained or withdrawn"
color={kpis.offRoster > 0 ? "yellow.8" : undefined}
tone={kpis.offRoster > 0 ? "yellow" : "edr-slate"}
icon={Wrench}
meter={kpis.total > 0 ? (kpis.offRoster / kpis.total) * 100 : 0}
/>
<StatTile
label="Loads · moves"
value={kpis.loads}
hint={`${kpis.moves} moves · mean ${kpis.meanLoads} loads per wagon, ${windowLabel}`}
value={formatCount(kpis.loads)}
tone="edr-blue"
icon={ChartColumn}
hint={`${formatCount(kpis.moves)} moves · mean ${kpis.meanLoads} loads per wagon, ${windowLabel}`}
/>
</SimpleGrid>
@@ -776,136 +914,88 @@ const WagonPerformancePage = () => {
<Stack gap="lg">
{/* ── Status mix + idle distribution ───────────── */}
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<Card withBorder radius="md" padding="md">
<Group justify="space-between" wrap="nowrap">
<Text fw={600}>Status mix</Text>
<SectionExportButton
label="status mix"
onExport={exportStatusMix}
/>
</Group>
<Text size="xs" c="dimmed" mt={4}>
{kpis.total} wagons on the register
</Text>
<Card withBorder radius="lg" padding="lg">
<CardHeader
title="Status mix"
subtitle={`${kpis.total} wagons on the register`}
action={
<SectionExportButton
label="status mix"
onExport={exportStatusMix}
/>
}
/>
{statusMix.length === 0 ? (
<Text size="sm" c="dimmed" py="lg" ta="center">
<Text size="sm" c="dimmed" py="xl" ta="center">
No wagons registered.
</Text>
) : (
<>
<Progress.Root size="lg" radius="xl" mt="md" mb="md">
<Box mt="lg" mb="lg">
<StackedBar
height={14}
unit="wagons"
segments={statusMix.map((s) => ({
key: s.status,
label: s.label,
value: s.count,
pct: s.pct,
tone: s.color,
}))}
/>
</Box>
<Stack gap={11}>
{statusMix.map((s) => (
<Progress.Section
<LegendRow
key={s.status}
value={s.pct}
color={s.color}
tone={s.color}
label={s.label}
value={s.count}
pct={s.pct}
/>
))}
</Progress.Root>
<Stack gap={9}>
{statusMix.map((s) => (
<Group
key={s.status}
justify="space-between"
gap="sm"
>
<Group gap={9} wrap="nowrap">
<Box
w={9}
h={9}
style={{
borderRadius: 3,
background: `var(--mantine-color-${s.color}-6)`,
}}
/>
<Text size="sm">{s.label}</Text>
</Group>
<Group gap="sm">
<Text size="sm" fw={700}>
{s.count}
</Text>
<Text size="xs" c="dimmed" w={34} ta="right">
{s.pct}%
</Text>
</Group>
</Group>
))}
</Stack>
</>
)}
</Card>
<Card withBorder radius="md" padding="md">
<Group justify="space-between" wrap="nowrap">
<Text fw={600}>Idle-day distribution</Text>
<SectionExportButton
label="idle distribution"
onExport={exportIdleDistribution}
/>
</Group>
<Text size="xs" c="dimmed" mt={4}>
Wagons by days without movement in their current yard
</Text>
<Group
align="flex-end"
gap="md"
h={170}
mt="lg"
wrap="nowrap"
>
{idleDistribution.map((b) => (
<Stack
key={b.label}
gap={6}
align="center"
justify="flex-end"
h="100%"
style={{ flex: 1 }}
>
<Text
size="xs"
fw={700}
c={
b.tone === "red"
? "red"
: b.tone === "yellow"
? "yellow.8"
: undefined
}
>
{b.count}
</Text>
<Box
w="100%"
h={`${Math.max(3, b.pct)}%`}
style={{
background: `var(--mantine-color-${b.tone}-6)`,
borderRadius: "5px 5px 0 0",
minHeight: 3,
}}
/>
<Text
size="xs"
c="dimmed"
style={{ whiteSpace: "nowrap" }}
>
{b.label}
</Text>
</Stack>
))}
<Card withBorder radius="lg" padding="lg">
<CardHeader
title="Idle-day distribution"
subtitle="Wagons by days without movement in their current yard"
action={
<SectionExportButton
label="idle distribution"
onExport={exportIdleDistribution}
/>
}
/>
{/* The bar colours are a severity scale, not identity, so
the key names the bands rather than each bucket. */}
<Group gap="lg" mt="sm">
<LegendKey tone="edr-green" label="Healthy" />
<LegendKey tone="yellow" label="Watch" />
<LegendKey tone="red" label="Stranded" />
</Group>
<ColumnChart data={idleDistribution} />
<Text
size="xs"
c="dimmed"
mt="md"
mt="lg"
pt="sm"
style={{
borderTop:
"1px solid var(--mantine-color-edr-divider-0)",
}}
>
<strong>{kpis.stranded}</strong> wagons have sat over{" "}
{IDLE_THRESHOLD_DAYS} days
<Text
span
fw={700}
c={kpis.stranded > 0 ? "red" : undefined}
>
{kpis.stranded}
</Text>{" "}
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 = () => {
</SimpleGrid>
{/* ── By yard ──────────────────────────────────── */}
<Card withBorder radius="md" padding={0}>
<Box p="md">
<Group justify="space-between" wrap="nowrap">
<Text fw={600}>By yard</Text>
<SectionExportButton
label="by-yard"
onExport={exportByYard}
/>
</Group>
<Text size="xs" c="dimmed" mt={4}>
Where the fleet is parked and how long it stays
</Text>
<Card withBorder radius="lg" padding={0}>
<Box p="lg" pb="md">
<CardHeader
title="By yard"
subtitle="Where the fleet is parked and how long it stays"
action={
<SectionExportButton
label="by-yard"
onExport={exportByYard}
/>
}
/>
</Box>
{byYard.length === 0 ? (
<Text size="sm" c="dimmed" py="lg" ta="center">
No wagons to group.
</Text>
) : (
<Table.ScrollContainer minWidth={640}>
<Table.ScrollContainer minWidth={720}>
<Table verticalSpacing="sm" horizontalSpacing="md">
<Table.Thead>
<Table.Tr>
<Table.Th>Yard</Table.Th>
<Table.Th w={200}>Share of fleet</Table.Th>
<Table.Th w={110} ta="right">
Wagons
</Table.Th>
@@ -985,12 +1076,60 @@ const WagonPerformancePage = () => {
{byYard.map((y) => (
<Table.Tr key={y.label}>
<Table.Td>
<Text size="sm" fw={600}>
{y.label}
</Text>
<Group gap={8} wrap="nowrap">
<MapPin
size={13}
color="var(--mantine-color-edr-muted-0)"
style={{ flexShrink: 0 }}
/>
<Text size="sm" fw={600}>
{y.label}
</Text>
</Group>
</Table.Td>
<Table.Td>
{/* Bar is scaled against the busiest yard, so
the biggest one always fills the track. */}
<Tooltip
withArrow
label={`${y.wagons} wagons · ${
kpis.total > 0
? Math.round(
(y.wagons / kpis.total) * 100,
)
: 0
}% of the fleet`}
>
<Box
h={6}
style={{
borderRadius: 999,
background:
"var(--mantine-color-edr-divider-0)",
}}
>
<Box
h="100%"
w={`${Math.max(
2,
(y.wagons / yardPeak) * 100,
)}%`}
style={{
borderRadius: 999,
background: toneVar("edr-blue"),
}}
/>
</Box>
</Tooltip>
</Table.Td>
<Table.Td ta="right">
<Text size="sm" fw={600}>
<Text
size="sm"
fw={600}
style={{
fontVariantNumeric: "tabular-nums",
}}
>
{y.wagons}
</Text>
</Table.Td>
@@ -1029,8 +1168,14 @@ const WagonPerformancePage = () => {
</Card>
{/* ── By wagon type ────────────────────────────── */}
<Card withBorder radius="md" padding={0}>
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Card withBorder radius="lg" padding={0}>
<Group
p="lg"
pb="md"
justify="space-between"
wrap="wrap"
gap="sm"
>
<div>
<Text fw={600}>By wagon type</Text>
<Text size="xs" c="dimmed" mt={4}>
@@ -1038,33 +1183,9 @@ const WagonPerformancePage = () => {
stuck
</Text>
</div>
<Group gap="md">
<Group gap={6}>
<Box
w={11}
h={5}
style={{
borderRadius: 2,
background: "var(--mantine-color-edr-green-6)",
}}
/>
<Text size="xs" c="dimmed">
Loaded
</Text>
</Group>
<Group gap={6}>
<Box
w={11}
h={5}
style={{
borderRadius: 2,
background: "var(--mantine-color-teal-4)",
}}
/>
<Text size="xs" c="dimmed">
Empty
</Text>
</Group>
<Group gap="lg">
<LegendKey tone="edr-green" label="Loaded" />
<LegendKey tone="teal.3" label="Empty" />
<SectionExportButton
label="by-type"
onExport={exportByType}
@@ -1122,21 +1243,23 @@ const WagonPerformancePage = () => {
</Table.Td>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<Progress.Root
size="sm"
radius="xl"
style={{ flex: 1 }}
<Box style={{ flex: 1 }}>
<SplitBar
primaryPct={t.loadedPct}
secondaryPct={t.emptyPct}
primaryLabel="Loaded"
secondaryLabel="Empty"
/>
</Box>
<Text
size="xs"
fw={600}
w={34}
ta="right"
style={{
fontVariantNumeric: "tabular-nums",
}}
>
<Progress.Section
value={t.loadedPct}
color="edr-green"
/>
<Progress.Section
value={t.emptyPct}
color="teal.4"
/>
</Progress.Root>
<Text size="xs" fw={600} w={34} ta="right">
{t.loadedPct}%
</Text>
</Group>
@@ -1491,7 +1614,15 @@ const WagonPerformancePage = () => {
}
w={72}
/>
<Text size="sm" fw={600} w={38} ta="right">
<Text
size="sm"
fw={600}
w={38}
ta="right"
style={{
fontVariantNumeric: "tabular-nums",
}}
>
{share}%
</Text>
</Group>

View File

@@ -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, 0100. */
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 (
<Box mt="lg">
<Box style={{ position: "relative", height, marginTop: 18 }}>
{/* 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) => (
<Box
key={i}
style={{
position: "absolute",
left: 0,
right: 0,
top: `${(i * 100) / 3}%`,
borderTop: `1px solid ${GRID_LINE}`,
pointerEvents: "none",
}}
/>
))}
<Group
align="flex-end"
gap="xs"
h="100%"
wrap="nowrap"
className="wp-col-chart"
style={{ position: "relative" }}
>
{data.map((d) => {
const isPeak = d.count === peak && peak > 0;
return (
<Tooltip
key={d.label}
withArrow
label={`${d.label} · ${d.count} ${unit}`}
>
<Stack
gap={0}
align="center"
justify="flex-end"
h="100%"
className="wp-col-slot"
style={{ flex: 1, cursor: "default" }}
>
{/* 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. */}
<Box
w="100%"
maw={28}
h={`${Math.max(2, d.pct)}%`}
className="wp-col-bar"
style={{
position: "relative",
background: toneVar(d.tone),
borderRadius: "4px 4px 0 0",
minHeight: 2,
transition: "opacity 120ms ease",
}}
>
{isPeak ? (
<Text
size="xs"
fw={700}
lh={1}
ta="center"
style={{
position: "absolute",
left: "50%",
bottom: "100%",
transform: "translateX(-50%)",
marginBottom: 5,
}}
>
{d.count}
</Text>
) : null}
</Box>
</Stack>
</Tooltip>
);
})}
</Group>
</Box>
{/* Baseline: one weight heavier than the gridlines, so zero reads. */}
<Box
style={{ borderTop: `1px solid var(--mantine-color-edr-border-0)` }}
/>
<Group gap="xs" wrap="nowrap" mt={8}>
{data.map((d) => (
<Text
key={d.label}
size="xs"
c="dimmed"
ta="center"
style={{ flex: 1, whiteSpace: "nowrap" }}
>
{d.label}
</Text>
))}
</Group>
</Box>
);
};
/* ────────────────────────────────────────────────────────────────────────── */
export interface StackSegment {
key: string;
label: string;
value: number;
/** Segment width as a share of the whole, 0100. */
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;
}) => (
<Group gap={2} wrap="nowrap" style={{ width: "100%" }}>
{segments
.filter((s) => s.value > 0)
.map((s, i, shown) => (
<Tooltip
key={s.key}
withArrow
label={`${s.label} · ${s.value}${unit ? ` ${unit}` : ""} (${s.pct}%)`}
>
<Box
h={height}
style={{
// Flex-grow by share, but never vanish: a 1-wagon status still
// needs a visible sliver to be hoverable.
flex: `${Math.max(s.pct, 0.5)} 1 0`,
minWidth: 3,
background: toneVar(s.tone),
borderRadius:
shown.length === 1
? 999
: i === 0
? "999px 2px 2px 999px"
: i === shown.length - 1
? "2px 999px 999px 2px"
: 2,
cursor: "default",
}}
/>
</Tooltip>
))}
</Group>
);
/* ────────────────────────────────────────────────────────────────────────── */
/**
* 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 (
<Group gap={both ? 2 : 0} wrap="nowrap" style={{ width: "100%" }}>
{primaryPct > 0 ? (
<Tooltip withArrow label={`${primaryLabel} · ${primaryPct}%`}>
<Box
h={8}
style={{
flex: `${primaryPct} 1 0`,
minWidth: 3,
background: toneVar(primaryTone),
borderRadius: both ? "999px 2px 2px 999px" : 999,
cursor: "default",
}}
/>
</Tooltip>
) : null}
{secondaryPct > 0 ? (
<Tooltip withArrow label={`${secondaryLabel} · ${secondaryPct}%`}>
<Box
h={8}
style={{
flex: `${secondaryPct} 1 0`,
minWidth: 3,
background: toneVar(secondaryTone),
borderRadius: both ? "2px 999px 999px 2px" : 999,
cursor: "default",
}}
/>
</Tooltip>
) : null}
{/* Nothing moved at all — an empty track, so the row still has a shape. */}
{primaryPct === 0 && secondaryPct === 0 ? (
<Box
h={8}
style={{
flex: 1,
background: "var(--mantine-color-edr-divider-0)",
borderRadius: 999,
}}
/>
) : null}
</Group>
);
};
/* ────────────────────────────────────────────────────────────────────────── */
/** 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;
}) => (
<Group justify="space-between" gap="sm" wrap="nowrap">
<Group gap={9} wrap="nowrap" style={{ minWidth: 0 }}>
<Box
w={8}
h={8}
style={{ borderRadius: 2, background: toneVar(tone), flexShrink: 0 }}
/>
<Text size="sm" truncate>
{label}
</Text>
</Group>
<Group gap="sm" wrap="nowrap">
<Text size="sm" fw={700} style={{ fontVariantNumeric: "tabular-nums" }}>
{value}
</Text>
{pct == null ? null : (
<Text
size="xs"
c="dimmed"
w={34}
ta="right"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{pct}%
</Text>
)}
</Group>
</Group>
);
/** Small square colour key used in a card header's inline legend. */
export const LegendKey = ({ tone, label }: { tone: string; label: string }) => (
<Group gap={6} wrap="nowrap">
<Box
w={10}
h={10}
style={{ borderRadius: 2, background: toneVar(tone), flexShrink: 0 }}
/>
<Text size="xs" c="dimmed">
{label}
</Text>
</Group>
);
/** 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;
}) => (
<Group justify="space-between" wrap="nowrap" align="flex-start" gap="sm">
<div style={{ minWidth: 0 }}>
<Text fw={600}>{title}</Text>
{subtitle ? (
<Text size="xs" c="dimmed" mt={4}>
{subtitle}
</Text>
) : null}
</div>
{action}
</Group>
);

View File

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