mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 01:18:18 +00:00
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:
@@ -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={
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<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, 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;
|
||||||
|
}) => (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user