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 = { 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 ( {label} {fmt(used)} {cap != null ? ` / ${fmt(cap)}` : ""} ); } function FreightIcon({ type }: { type: string | null }) { const Icon = type === "BULK" ? Boxes : Container; return ( ); } /** 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 ( {gov ? ( ) : ( {rank} )} {booking.reference} {gov ? ( ) : null} {booking.company} {/* score */} {booking.priorityScore} {/* wagons + weight this booking adds */} {booking.wagons}w {fmtTons(booking.weightTons)} {/* verdict */} {selected ? ( } > Would board ) : ( Waiting list )} ); } /** Cut line between the simulated batch and the simulated waiting list. */ function CutLine({ full }: { full: boolean }) { return ( Forecast capacity line{full ? " · TRAIN FULL" : ""} ); } /** * 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 ( {/* Header + explainer */} Forecast batch (simulated) Preview 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. {sim.selected.length} would board {sim.waiting.length} waiting list {/* Three capacity axes */} `${n}`} color="edr-green" /> {noCaps ? ( } > No locomotive / capacity limits on this schedule yet — forecast can't draw the capacity line. Assign a locomotive to simulate the fill. ) : null} {sim.rows.length === 0 ? ( No eligible bookings to forecast yet. ) : ( {/* WOULD BOARD */} {sim.selected.length > 0 ? ( Projected batch{" "} ({sim.selected.length}) — top priority, fits capacity {sim.selected.map((r) => ( ))} ) : null} {/* WAITING LIST */} {sim.waiting.length > 0 ? ( Projected waiting list{" "} ({sim.waiting.length}) — boards only if a slot frees up {sim.waiting.map((r) => ( ))} ) : null} {/* INELIGIBLE (expired / pending contract) */} {sim.ineligible.length > 0 ? ( {sim.ineligible.length} booking {sim.ineligible.length === 1 ? "" : "s"} not in the forecast (expired or contract not signed). ) : null} )} ); } export default ForecastPanel;