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 BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
import ScheduleCrewPage from "./pages/trainScheduling/ScheduleCrewPage";
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
import TradeAccessPage from "./pages/configuration/TradeAccessPage"; import TradeAccessPage from "./pages/configuration/TradeAccessPage";
import OperationsStandardsPage from "./pages/settings/OperationsStandardsPage"; import OperationsStandardsPage from "./pages/settings/OperationsStandardsPage";
@@ -900,6 +901,14 @@ const App = () => {
</RequirePermission> </RequirePermission>
} }
/> />
<Route
path="operations/train-scheduling-v2/:scheduleId/crew"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<ScheduleCrewPage />
</RequirePermission>
}
/>
<Route <Route
path="routes" path="routes"
element={ 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, Send,
Table2, Table2,
Train, Train,
Users,
Weight, Weight,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
@@ -372,12 +373,26 @@ export default function TrainScheduleV2ListPage() {
}, },
{ {
id: "actions", id: "actions",
size: 32, size: 210,
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` }, meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => { cell: ({ row }) => {
const schedule = row.original; const schedule = row.original;
return ( 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 position="bottom-end" withinPortal shadow="md" width={190}>
<Menu.Target> <Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="Row actions"> <ActionIcon variant="subtle" color="gray" aria-label="Row actions">
@@ -639,6 +654,9 @@ export default function TrainScheduleV2ListPage() {
onTrack={() => onTrack={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`) navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`)
} }
onAssignCrew={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}/crew`)
}
/> />
))} ))}
</SimpleGrid> </SimpleGrid>
@@ -1053,10 +1071,12 @@ function ScheduleCard({
schedule, schedule,
onOpen, onOpen,
onTrack, onTrack,
onAssignCrew,
}: { }: {
schedule: TrainScheduleListItem; schedule: TrainScheduleListItem;
onOpen: () => void; onOpen: () => void;
onTrack: () => void; onTrack: () => void;
onAssignCrew: () => void;
}) { }) {
const { day, time } = splitDate(schedule.scheduleDate); const { day, time } = splitDate(schedule.scheduleDate);
const canTrack = ["DISPATCHED", "ARRIVED"].includes(schedule.status); const canTrack = ["DISPATCHED", "ARRIVED"].includes(schedule.status);
@@ -1153,6 +1173,19 @@ function ScheduleCard({
Track Track
</Button> </Button>
) : null} ) : null}
<Button
variant="light"
color="indigo"
size="sm"
radius="md"
leftSection={<Users size={15} />}
onClick={(e) => {
e.stopPropagation();
onAssignCrew();
}}
>
Assign Train Crew
</Button>
</Group> </Group>
</Stack> </Stack>
</Card> </Card>

View File

@@ -29,9 +29,14 @@ import {
ArrowUp, ArrowUp,
ChartColumn, ChartColumn,
ChevronRight, ChevronRight,
CircleCheck,
List, List,
MapPin, MapPin,
PauseCircle,
Search, Search,
TrainFront,
Wrench,
type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs"; import Breadcrumbs from "@/components/ui/Breadcrumbs";
@@ -48,6 +53,17 @@ import {
} from "./wagonPerformance"; } from "./wagonPerformance";
import { downloadSheet, downloadSheets } from "./exportSection"; import { downloadSheet, downloadSheets } from "./exportSection";
import { SectionExportButton } from "./SectionExportButton"; import { SectionExportButton } from "./SectionExportButton";
import {
CardHeader,
ColumnChart,
LegendKey,
LegendRow,
SplitBar,
StackedBar,
formatCount,
toneVar,
} from "./chartKit";
import "./wagonPerformance.css";
const WINDOWS = [ const WINDOWS = [
{ value: "30", label: "30d" }, { value: "30", label: "30d" },
@@ -89,25 +105,78 @@ const IDLE_BUCKETS: Array<{
{ label: "46 d +", min: 46, max: Infinity, tone: "red" }, { 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 = ({ const StatTile = ({
label, label,
value, value,
hint, hint,
color, color,
tone = "gray",
icon: Icon,
meter,
}: { }: {
label: string; label: string;
value: React.ReactNode; value: React.ReactNode;
hint: string; hint: string;
color?: string; color?: string;
tone?: string;
icon?: LucideIcon;
meter?: number;
}) => ( }) => (
<Card withBorder radius="md" padding="md"> <Card
<Text size="xs" c="dimmed" tt="uppercase" fw={600}> withBorder
{label} radius="lg"
</Text> padding="md"
<Text size="26px" fw={700} lh={1.1} mt={8} c={color}> 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} {value}
</Text> </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} {hint}
</Text> </Text>
</Card> </Card>
@@ -140,7 +209,13 @@ const SortHeader = ({
</UnstyledButton> </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 = ({ const Leaderboard = ({
title, title,
subtitle, subtitle,
@@ -151,69 +226,114 @@ const Leaderboard = ({
title: string; title: string;
subtitle: string; subtitle: string;
accent: 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; onOpen: (id: string) => void;
}) => ( }) => {
<Card withBorder radius="md" padding={0}> const peak = Math.max(1, ...rows.map((r) => r.weight ?? 0));
<Box
p="md" return (
pb="sm" <Card withBorder radius="lg" padding={0} style={{ overflow: "hidden" }}>
style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }} <Box style={{ height: 3, background: toneVar(accent) }} />
> <Box
<Group gap={8} wrap="nowrap"> p="md"
<Box pb="sm"
w={7} style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}
h={7} >
style={{
borderRadius: 2,
background: `var(--mantine-color-${accent}-6)`,
}}
/>
<Text fw={600} size="sm"> <Text fw={600} size="sm">
{title} {title}
</Text> </Text>
</Group> <Text size="xs" c="dimmed" mt={4}>
<Text size="xs" c="dimmed" mt={4}> {subtitle}
{subtitle} </Text>
</Text> </Box>
</Box> {rows.length === 0 ? (
{rows.length === 0 ? ( <Text size="sm" c="dimmed" py="xl" ta="center">
<Text size="sm" c="dimmed" py="lg" ta="center"> Nothing to rank yet.
Nothing to rank yet. </Text>
</Text> ) : (
) : ( <Stack gap={0} py={4}>
<Stack gap={0} py={4}> {rows.map((r, i) => (
{rows.map((r, i) => ( <UnstyledButton
<UnstyledButton key={r.id}
key={r.id} onClick={() => onOpen(r.id)}
onClick={() => onOpen(r.id)} px="md"
px="md" py={10}
py={9} className="wp-rank-row"
> >
<Group justify="space-between" wrap="nowrap" gap="sm"> <Group justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}> <Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="xs" fw={600} c="dimmed" w={14}> {/* Medallion: the top three carry the board's own tone. */}
{i + 1} <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> </Text>
<div style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{r.number}
</Text>
<Text size="xs" c="dimmed" truncate>
{r.note}
</Text>
</div>
</Group> </Group>
<Text size="sm" fw={700} style={{ whiteSpace: "nowrap" }}> {r.weight == null ? null : (
{r.value} <Box
</Text> mt={8}
</Group> ml={30}
</UnstyledButton> h={3}
))} style={{
</Stack> borderRadius: 999,
)} background: "var(--mantine-color-edr-divider-0)",
</Card> }}
); >
<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 * 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); .sort((a, b) => b.wagons - a.wagons);
}, [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. */ /** Which classes of stock earn, and which sit. */
const byType = useMemo(() => { const byType = useMemo(() => {
const rows = new Map< const rows = new Map<
@@ -425,6 +548,7 @@ const WagonPerformancePage = () => {
number: w.wagonNumber, number: w.wagonNumber,
note: `${w.wagonType?.code ?? "—"} · ${yardOf(w)}`, note: `${w.wagonType?.code ?? "—"} · ${yardOf(w)}`,
value: `${w.loadsInWindow ?? 0} loads`, value: `${w.loadsInWindow ?? 0} loads`,
weight: w.loadsInWindow ?? 0,
})), })),
stranded: withIdle stranded: withIdle
.sort((a, b) => b.idle - a.idle) .sort((a, b) => b.idle - a.idle)
@@ -434,6 +558,7 @@ const WagonPerformancePage = () => {
number: w.wagonNumber, number: w.wagonNumber,
note: `${w.wagonType?.code ?? "—"} · ${yardOf(w)}`, note: `${w.wagonType?.code ?? "—"} · ${yardOf(w)}`,
value: `${idle} days`, value: `${idle} days`,
weight: idle,
})), })),
idle: [...wagons] idle: [...wagons]
.filter((w) => (w.movesInWindow ?? 0) === 0) .filter((w) => (w.movesInWindow ?? 0) === 0)
@@ -721,12 +846,17 @@ const WagonPerformancePage = () => {
<SimpleGrid cols={{ base: 1, sm: 2, lg: 5 }} spacing="md"> <SimpleGrid cols={{ base: 1, sm: 2, lg: 5 }} spacing="md">
<StatTile <StatTile
label="Fleet size" label="Fleet size"
value={kpis.total} value={formatCount(kpis.total)}
hint="Wagons on the register" hint="Wagons on the register"
tone="edr-slate"
icon={TrainFront}
/> />
<StatTile <StatTile
label="In service" 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={ hint={
kpis.total > 0 kpis.total > 0
? `${Math.round((kpis.inService / kpis.total) * 100)}% of the fleet` ? `${Math.round((kpis.inService / kpis.total) * 100)}% of the fleet`
@@ -735,20 +865,28 @@ const WagonPerformancePage = () => {
/> />
<StatTile <StatTile
label={`Idle over ${IDLE_THRESHOLD_DAYS}d`} label={`Idle over ${IDLE_THRESHOLD_DAYS}d`}
value={kpis.stranded} value={formatCount(kpis.stranded)}
hint="No movement in the current yard" hint="No movement in the current yard"
color={kpis.stranded > 0 ? "red" : undefined} 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 <StatTile
label="Off roster" label="Off roster"
value={kpis.offRoster} value={formatCount(kpis.offRoster)}
hint="Maintenance, detained or withdrawn" hint="Maintenance, detained or withdrawn"
color={kpis.offRoster > 0 ? "yellow.8" : undefined} 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 <StatTile
label="Loads · moves" label="Loads · moves"
value={kpis.loads} value={formatCount(kpis.loads)}
hint={`${kpis.moves} moves · mean ${kpis.meanLoads} loads per wagon, ${windowLabel}`} tone="edr-blue"
icon={ChartColumn}
hint={`${formatCount(kpis.moves)} moves · mean ${kpis.meanLoads} loads per wagon, ${windowLabel}`}
/> />
</SimpleGrid> </SimpleGrid>
@@ -776,136 +914,88 @@ const WagonPerformancePage = () => {
<Stack gap="lg"> <Stack gap="lg">
{/* ── Status mix + idle distribution ───────────── */} {/* ── Status mix + idle distribution ───────────── */}
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md"> <SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<Card withBorder radius="md" padding="md"> <Card withBorder radius="lg" padding="lg">
<Group justify="space-between" wrap="nowrap"> <CardHeader
<Text fw={600}>Status mix</Text> title="Status mix"
<SectionExportButton subtitle={`${kpis.total} wagons on the register`}
label="status mix" action={
onExport={exportStatusMix} <SectionExportButton
/> label="status mix"
</Group> onExport={exportStatusMix}
<Text size="xs" c="dimmed" mt={4}> />
{kpis.total} wagons on the register }
</Text> />
{statusMix.length === 0 ? ( {statusMix.length === 0 ? (
<Text size="sm" c="dimmed" py="lg" ta="center"> <Text size="sm" c="dimmed" py="xl" ta="center">
No wagons registered. No wagons registered.
</Text> </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) => ( {statusMix.map((s) => (
<Progress.Section <LegendRow
key={s.status} key={s.status}
value={s.pct} tone={s.color}
color={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> </Stack>
</> </>
)} )}
</Card> </Card>
<Card withBorder radius="md" padding="md"> <Card withBorder radius="lg" padding="lg">
<Group justify="space-between" wrap="nowrap"> <CardHeader
<Text fw={600}>Idle-day distribution</Text> title="Idle-day distribution"
<SectionExportButton subtitle="Wagons by days without movement in their current yard"
label="idle distribution" action={
onExport={exportIdleDistribution} <SectionExportButton
/> label="idle distribution"
</Group> onExport={exportIdleDistribution}
<Text size="xs" c="dimmed" mt={4}> />
Wagons by days without movement in their current yard }
</Text> />
<Group {/* The bar colours are a severity scale, not identity, so
align="flex-end" the key names the bands rather than each bucket. */}
gap="md" <Group gap="lg" mt="sm">
h={170} <LegendKey tone="edr-green" label="Healthy" />
mt="lg" <LegendKey tone="yellow" label="Watch" />
wrap="nowrap" <LegendKey tone="red" label="Stranded" />
>
{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>
))}
</Group> </Group>
<ColumnChart data={idleDistribution} />
<Text <Text
size="xs" size="xs"
c="dimmed" c="dimmed"
mt="md" mt="lg"
pt="sm" pt="sm"
style={{ style={{
borderTop: borderTop:
"1px solid var(--mantine-color-edr-divider-0)", "1px solid var(--mantine-color-edr-divider-0)",
}} }}
> >
<strong>{kpis.stranded}</strong> wagons have sat over{" "} <Text
{IDLE_THRESHOLD_DAYS} days span
fw={700}
c={kpis.stranded > 0 ? "red" : undefined}
>
{kpis.stranded}
</Text>{" "}
wagons have sat over {IDLE_THRESHOLD_DAYS} days
{kpis.total > 0 {kpis.total > 0
? `${Math.round((kpis.stranded / kpis.total) * 100)}% of the fleet locked up` ? `${Math.round((kpis.stranded / kpis.total) * 100)}% of the fleet locked up`
: ""} : ""}
@@ -947,29 +1037,30 @@ const WagonPerformancePage = () => {
</SimpleGrid> </SimpleGrid>
{/* ── By yard ──────────────────────────────────── */} {/* ── By yard ──────────────────────────────────── */}
<Card withBorder radius="md" padding={0}> <Card withBorder radius="lg" padding={0}>
<Box p="md"> <Box p="lg" pb="md">
<Group justify="space-between" wrap="nowrap"> <CardHeader
<Text fw={600}>By yard</Text> title="By yard"
<SectionExportButton subtitle="Where the fleet is parked and how long it stays"
label="by-yard" action={
onExport={exportByYard} <SectionExportButton
/> label="by-yard"
</Group> onExport={exportByYard}
<Text size="xs" c="dimmed" mt={4}> />
Where the fleet is parked and how long it stays }
</Text> />
</Box> </Box>
{byYard.length === 0 ? ( {byYard.length === 0 ? (
<Text size="sm" c="dimmed" py="lg" ta="center"> <Text size="sm" c="dimmed" py="lg" ta="center">
No wagons to group. No wagons to group.
</Text> </Text>
) : ( ) : (
<Table.ScrollContainer minWidth={640}> <Table.ScrollContainer minWidth={720}>
<Table verticalSpacing="sm" horizontalSpacing="md"> <Table verticalSpacing="sm" horizontalSpacing="md">
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>Yard</Table.Th> <Table.Th>Yard</Table.Th>
<Table.Th w={200}>Share of fleet</Table.Th>
<Table.Th w={110} ta="right"> <Table.Th w={110} ta="right">
Wagons Wagons
</Table.Th> </Table.Th>
@@ -985,12 +1076,60 @@ const WagonPerformancePage = () => {
{byYard.map((y) => ( {byYard.map((y) => (
<Table.Tr key={y.label}> <Table.Tr key={y.label}>
<Table.Td> <Table.Td>
<Text size="sm" fw={600}> <Group gap={8} wrap="nowrap">
{y.label} <MapPin
</Text> 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>
<Table.Td ta="right"> <Table.Td ta="right">
<Text size="sm" fw={600}> <Text
size="sm"
fw={600}
style={{
fontVariantNumeric: "tabular-nums",
}}
>
{y.wagons} {y.wagons}
</Text> </Text>
</Table.Td> </Table.Td>
@@ -1029,8 +1168,14 @@ const WagonPerformancePage = () => {
</Card> </Card>
{/* ── By wagon type ────────────────────────────── */} {/* ── By wagon type ────────────────────────────── */}
<Card withBorder radius="md" padding={0}> <Card withBorder radius="lg" padding={0}>
<Group p="md" justify="space-between" wrap="wrap" gap="sm"> <Group
p="lg"
pb="md"
justify="space-between"
wrap="wrap"
gap="sm"
>
<div> <div>
<Text fw={600}>By wagon type</Text> <Text fw={600}>By wagon type</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
@@ -1038,33 +1183,9 @@ const WagonPerformancePage = () => {
stuck stuck
</Text> </Text>
</div> </div>
<Group gap="md"> <Group gap="lg">
<Group gap={6}> <LegendKey tone="edr-green" label="Loaded" />
<Box <LegendKey tone="teal.3" label="Empty" />
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>
<SectionExportButton <SectionExportButton
label="by-type" label="by-type"
onExport={exportByType} onExport={exportByType}
@@ -1122,21 +1243,23 @@ const WagonPerformancePage = () => {
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<Progress.Root <Box style={{ flex: 1 }}>
size="sm" <SplitBar
radius="xl" primaryPct={t.loadedPct}
style={{ flex: 1 }} 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}% {t.loadedPct}%
</Text> </Text>
</Group> </Group>
@@ -1491,7 +1614,15 @@ const WagonPerformancePage = () => {
} }
w={72} 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}% {share}%
</Text> </Text>
</Group> </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;
}