mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 05:30:55 +00:00
405 lines
12 KiB
TypeScript
405 lines
12 KiB
TypeScript
import { useMemo } from "react";
|
|
import {
|
|
Alert,
|
|
Badge,
|
|
Box,
|
|
Group,
|
|
Paper,
|
|
Progress,
|
|
Stack,
|
|
Text,
|
|
ThemeIcon,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
import {
|
|
Crown,
|
|
Container,
|
|
Boxes,
|
|
FlaskConical,
|
|
Layers,
|
|
Ruler,
|
|
Scale,
|
|
Sparkles,
|
|
TrainFront,
|
|
Trophy,
|
|
XCircle,
|
|
} from "lucide-react";
|
|
|
|
import type { BatchBoardScheduleDetail } from "@/types/trainScheduling";
|
|
import {
|
|
simulateBatch,
|
|
limitsFromDetail,
|
|
type BlockingAxis,
|
|
type ForecastRow,
|
|
} from "./batchForecast";
|
|
|
|
type Props = {
|
|
data: BatchBoardScheduleDetail;
|
|
bookings: BatchBoardScheduleDetail["pendingContract"]["bookings"];
|
|
};
|
|
|
|
const cardVar = (color: string, shade: number) =>
|
|
`var(--mantine-color-${color}-${shade})`;
|
|
|
|
const fmtTons = (n: number) =>
|
|
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
|
|
const fmtMeters = (n: number) =>
|
|
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
|
|
|
|
const AXIS_LABEL: Record<BlockingAxis, string> = {
|
|
wagons: "wagon slots full",
|
|
weight: "over max pull weight",
|
|
length: "over train length",
|
|
};
|
|
|
|
/** One capacity axis as a labelled meter (used vs cap). */
|
|
function AxisMeter({
|
|
icon: Icon,
|
|
label,
|
|
used,
|
|
cap,
|
|
fmt,
|
|
color,
|
|
}: {
|
|
icon: typeof Scale;
|
|
label: string;
|
|
used: number;
|
|
cap: number | null;
|
|
fmt: (n: number) => string;
|
|
color: string;
|
|
}) {
|
|
const pct = cap && cap > 0 ? Math.min(100, (used / cap) * 100) : 0;
|
|
const near = pct >= 90;
|
|
return (
|
|
<Box style={{ flex: 1, minWidth: 150 }}>
|
|
<Group justify="space-between" mb={4} wrap="nowrap">
|
|
<Group gap={5} wrap="nowrap">
|
|
<Icon size={13} color={cardVar(color, 6)} />
|
|
<Text size="xs" c="dimmed" fw={600}>
|
|
{label}
|
|
</Text>
|
|
</Group>
|
|
<Text size="xs" fw={700} c={near ? `${color}.8` : "dark.4"}>
|
|
{fmt(used)}
|
|
{cap != null ? ` / ${fmt(cap)}` : ""}
|
|
</Text>
|
|
</Group>
|
|
<Progress
|
|
value={pct}
|
|
size="md"
|
|
radius="xl"
|
|
color={near ? color : "edr-green"}
|
|
/>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function FreightIcon({ type }: { type: string | null }) {
|
|
const Icon = type === "BULK" ? Boxes : Container;
|
|
return (
|
|
<Tooltip label={type === "BULK" ? "Bulk" : "Container"} withArrow>
|
|
<ThemeIcon size="sm" radius="sm" variant="light" color="gray">
|
|
<Icon size={13} />
|
|
</ThemeIcon>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
/** A single forecast row: rank, booking, capacity contribution, projected verdict. */
|
|
function ForecastCard({ row }: { row: ForecastRow }) {
|
|
const { booking, rank, selected, blockedBy } = row;
|
|
const gov = booking.isGovernment;
|
|
|
|
return (
|
|
<Paper
|
|
radius="md"
|
|
p="sm"
|
|
withBorder
|
|
style={{
|
|
borderColor: selected
|
|
? cardVar("edr-green", 3)
|
|
: cardVar("gray", 2),
|
|
background: selected
|
|
? `linear-gradient(90deg, ${cardVar("edr-green", 0)} 0%, var(--mantine-color-white) 55%)`
|
|
: "var(--mantine-color-white)",
|
|
opacity: selected ? 1 : 0.92,
|
|
}}
|
|
>
|
|
<Group justify="space-between" wrap="nowrap" gap="sm">
|
|
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0 }}>
|
|
<ThemeIcon
|
|
size={32}
|
|
radius="xl"
|
|
variant={selected && rank <= 3 ? "filled" : "light"}
|
|
color={gov ? "grape" : selected ? "edr-green" : "gray"}
|
|
style={{ flexShrink: 0, fontWeight: 800 }}
|
|
>
|
|
{gov ? (
|
|
<Crown size={15} />
|
|
) : (
|
|
<Text fw={800} size="sm">
|
|
{rank}
|
|
</Text>
|
|
)}
|
|
</ThemeIcon>
|
|
<Stack gap={2} style={{ minWidth: 0 }}>
|
|
<Group gap={6} wrap="nowrap">
|
|
<Text fw={700} size="sm" truncate>
|
|
{booking.reference}
|
|
</Text>
|
|
<FreightIcon type={booking.freightType} />
|
|
{gov ? (
|
|
<Tooltip label="Government — boards first" withArrow>
|
|
<ThemeIcon size="xs" radius="sm" variant="light" color="grape">
|
|
<Crown size={10} />
|
|
</ThemeIcon>
|
|
</Tooltip>
|
|
) : null}
|
|
</Group>
|
|
<Text size="xs" c="dimmed" truncate>
|
|
{booking.company}
|
|
</Text>
|
|
</Stack>
|
|
</Group>
|
|
|
|
<Group wrap="nowrap" gap="lg" style={{ flexShrink: 0 }}>
|
|
{/* score */}
|
|
<Group gap={4} wrap="nowrap" w={70} justify="flex-end">
|
|
<Trophy size={12} color={cardVar("edr-green", 6)} />
|
|
<Text fw={800} size="sm" c="edr-green.7">
|
|
{booking.priorityScore}
|
|
</Text>
|
|
</Group>
|
|
{/* wagons + weight this booking adds */}
|
|
<Group gap={4} wrap="nowrap" w={64} justify="flex-end">
|
|
<TrainFront size={13} color={cardVar("gray", 6)} />
|
|
<Text fw={700} size="sm">
|
|
{booking.wagons}w
|
|
</Text>
|
|
</Group>
|
|
<Text size="xs" c="dimmed" w={64} ta="right">
|
|
{fmtTons(booking.weightTons)}
|
|
</Text>
|
|
{/* verdict */}
|
|
<Box w={150} style={{ textAlign: "right" }}>
|
|
{selected ? (
|
|
<Badge
|
|
variant="light"
|
|
color="edr-green"
|
|
radius="sm"
|
|
leftSection={<Sparkles size={11} />}
|
|
>
|
|
Would board
|
|
</Badge>
|
|
) : (
|
|
<Tooltip
|
|
label={
|
|
blockedBy
|
|
? `Doesn't fit — ${AXIS_LABEL[blockedBy]}`
|
|
: "Below the capacity line"
|
|
}
|
|
withArrow
|
|
>
|
|
<Badge variant="light" color="gray" radius="sm">
|
|
Waiting list
|
|
</Badge>
|
|
</Tooltip>
|
|
)}
|
|
</Box>
|
|
</Group>
|
|
</Group>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
/** Cut line between the simulated batch and the simulated waiting list. */
|
|
function CutLine({ full }: { full: boolean }) {
|
|
return (
|
|
<Group gap="xs" my={2} wrap="nowrap">
|
|
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
|
|
<Group gap={6} wrap="nowrap">
|
|
<ThemeIcon size="sm" radius="xl" variant="light" color="orange">
|
|
<Layers size={12} />
|
|
</ThemeIcon>
|
|
<Text size="xs" fw={700} c="orange.7">
|
|
Forecast capacity line{full ? " · TRAIN FULL" : ""}
|
|
</Text>
|
|
</Group>
|
|
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Forecast / "what-if" panel. Simulates the batch engine's greedy fill on the
|
|
* current pool and shows the projected winners + waiting list BEFORE document
|
|
* review closes. Not the real selection — the engine commits that when staff run
|
|
* the batch after the review window ends.
|
|
*/
|
|
export function ForecastPanel({ data, bookings }: Props) {
|
|
const limits = useMemo(() => limitsFromDetail(data), [data]);
|
|
const sim = useMemo(
|
|
() => simulateBatch(bookings, limits),
|
|
[bookings, limits],
|
|
);
|
|
|
|
const noCaps =
|
|
limits.maxWagons == null &&
|
|
limits.maxWeightTons == null &&
|
|
limits.maxLengthMeters == null;
|
|
|
|
return (
|
|
<Stack gap="lg">
|
|
{/* Header + explainer */}
|
|
<Paper radius="lg" withBorder p="lg">
|
|
<Group justify="space-between" wrap="wrap" gap="md" mb="md">
|
|
<Group gap="sm">
|
|
<ThemeIcon variant="light" color="violet" radius="md" size="lg">
|
|
<FlaskConical size={18} />
|
|
</ThemeIcon>
|
|
<Stack gap={2}>
|
|
<Group gap={8}>
|
|
<Text fw={700}>Forecast batch (simulated)</Text>
|
|
<Badge variant="light" color="violet" radius="sm" size="sm">
|
|
Preview
|
|
</Badge>
|
|
</Group>
|
|
<Text size="xs" c="dimmed" maw={520}>
|
|
What the batch engine would pick if it ran now — greedy fill by
|
|
priority until the train is full. The real selection happens when
|
|
document review ends and staff run the batch.
|
|
</Text>
|
|
</Stack>
|
|
</Group>
|
|
<Group gap="lg">
|
|
<Stack gap={0} align="flex-end">
|
|
<Text size="xl" fw={800} c="edr-green.7">
|
|
{sim.selected.length}
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
would board
|
|
</Text>
|
|
</Stack>
|
|
<Stack gap={0} align="flex-end">
|
|
<Text size="xl" fw={800} c="gray.7">
|
|
{sim.waiting.length}
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
waiting list
|
|
</Text>
|
|
</Stack>
|
|
</Group>
|
|
</Group>
|
|
|
|
{/* Three capacity axes */}
|
|
<Group gap="lg" align="flex-end" wrap="wrap">
|
|
<AxisMeter
|
|
icon={TrainFront}
|
|
label="Wagon slots"
|
|
used={sim.usedWagons}
|
|
cap={limits.maxWagons}
|
|
fmt={(n) => `${n}`}
|
|
color="edr-green"
|
|
/>
|
|
<AxisMeter
|
|
icon={Scale}
|
|
label="Max pull weight"
|
|
used={sim.usedWeightTons}
|
|
cap={limits.maxWeightTons}
|
|
fmt={fmtTons}
|
|
color="orange"
|
|
/>
|
|
<AxisMeter
|
|
icon={Ruler}
|
|
label="Train length"
|
|
used={sim.usedLengthMeters}
|
|
cap={limits.maxLengthMeters}
|
|
fmt={fmtMeters}
|
|
color="blue"
|
|
/>
|
|
</Group>
|
|
|
|
{noCaps ? (
|
|
<Alert
|
|
color="yellow"
|
|
mt="md"
|
|
radius="md"
|
|
icon={<XCircle size={16} />}
|
|
>
|
|
No locomotive / capacity limits on this schedule yet — forecast can't
|
|
draw the capacity line. Assign a locomotive to simulate the fill.
|
|
</Alert>
|
|
) : null}
|
|
</Paper>
|
|
|
|
{sim.rows.length === 0 ? (
|
|
<Paper radius="lg" withBorder p="xl">
|
|
<Text c="dimmed" ta="center">
|
|
No eligible bookings to forecast yet.
|
|
</Text>
|
|
</Paper>
|
|
) : (
|
|
<Stack gap={6}>
|
|
{/* WOULD BOARD */}
|
|
{sim.selected.length > 0 ? (
|
|
<Stack gap={6}>
|
|
<Group gap="xs">
|
|
<ThemeIcon
|
|
size="sm"
|
|
radius="sm"
|
|
variant="light"
|
|
color="edr-green"
|
|
>
|
|
<Sparkles size={13} />
|
|
</ThemeIcon>
|
|
<Text fw={700} size="sm">
|
|
Projected batch{" "}
|
|
<Text span c="dimmed" fw={500}>
|
|
({sim.selected.length}) — top priority, fits capacity
|
|
</Text>
|
|
</Text>
|
|
</Group>
|
|
{sim.selected.map((r) => (
|
|
<ForecastCard key={r.booking.id} row={r} />
|
|
))}
|
|
</Stack>
|
|
) : null}
|
|
|
|
<CutLine full={sim.full} />
|
|
|
|
{/* WAITING LIST */}
|
|
{sim.waiting.length > 0 ? (
|
|
<Stack gap={6}>
|
|
<Group gap="xs">
|
|
<ThemeIcon size="sm" radius="sm" variant="light" color="gray">
|
|
<Layers size={13} />
|
|
</ThemeIcon>
|
|
<Text fw={700} size="sm">
|
|
Projected waiting list{" "}
|
|
<Text span c="dimmed" fw={500}>
|
|
({sim.waiting.length}) — boards only if a slot frees up
|
|
</Text>
|
|
</Text>
|
|
</Group>
|
|
{sim.waiting.map((r) => (
|
|
<ForecastCard key={r.booking.id} row={r} />
|
|
))}
|
|
</Stack>
|
|
) : null}
|
|
|
|
{/* INELIGIBLE (expired / pending contract) */}
|
|
{sim.ineligible.length > 0 ? (
|
|
<Text size="xs" c="dimmed" mt={4}>
|
|
{sim.ineligible.length} booking
|
|
{sim.ineligible.length === 1 ? "" : "s"} not in the forecast
|
|
(expired or contract not signed).
|
|
</Text>
|
|
) : null}
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
export default ForecastPanel;
|