import { memo, useMemo, useState, type ReactNode } from "react"; import { Box, Group, Paper, Progress, SegmentedControl, Stack, Text, ThemeIcon, Tooltip, } from "@mantine/core"; import { Boxes, CheckCircle2, Clock, Container, Crown, FlaskConical, Hourglass, Layers, ListOrdered, Radio, TrainFront, Trophy, XCircle, } from "lucide-react"; import { CountdownTimer } from "@edr/ui-common"; import type { BatchBoardBookingDetail, BatchBoardBookingState, BatchBoardScheduleDetail, } from "@/types/trainScheduling"; import { WindowPhasePill } from "./batchVisuals"; import { ForecastPanel } from "./ForecastPanel"; import { forecastIsLive, rankBookings } from "./batchForecast"; /** * Priority Tracking tab — live, glanceable ranking of every booking on this * schedule in the exact order the batch engine boards them (government first, * then window cycle — bookings compete only within their own cycle — then * rule-engine priority score, then oldest). Bookings above the train's * wagon-capacity line render as "selected" (green), below it as the waiting * list; during the PAYMENT phase selected bookings show a live pay-window * countdown. Purely presentational — data comes from the batch-board detail * response the page already polls (+ socket-invalidates). */ type Props = { data: BatchBoardScheduleDetail; bookings: BatchBoardBookingDetail[]; }; const STATE_STYLE: Record< BatchBoardBookingState, { label: string; color: string; icon: typeof CheckCircle2 } > = { ALLOCATED: { label: "Allocated", color: "edr-green", icon: CheckCircle2 }, SELECTED_FOR_BATCH: { label: "Selected · pay now", color: "orange", icon: Clock }, READY: { label: "Ready", color: "teal", icon: Hourglass }, WAITING: { label: "Paid · waiting slot", color: "blue", icon: Hourglass }, PENDING_CONTRACT: { label: "Pending contract", color: "gray", icon: Hourglass }, EXPIRED: { label: "Expired", color: "red", icon: XCircle }, }; /** States that occupy a wagon slot on this train (i.e. are "in" the batch). */ const OCCUPIES_SLOT: BatchBoardBookingState[] = [ "ALLOCATED", "SELECTED_FOR_BATCH", "WAITING", ]; const cardVar = (color: string, shade: number) => `var(--mantine-color-${color}-${shade})`; /** Highest score across the ranked pool → used to scale the priority mini-bar. */ function maxScore(bookings: BatchBoardBookingDetail[]): number { return bookings.reduce((m, b) => Math.max(m, b.priorityScore ?? 0), 0); } function FreightIcon({ type }: { type: string | null }) { const Icon = type === "BULK" ? Boxes : Container; return ( ); } /** One ranked booking row rendered as a card, colored by its batch state. */ function RankedCard({ rank, booking, scoreMax, phase, isPayPhase, }: { rank: number; booking: BatchBoardBookingDetail; scoreMax: number; phase: string | null; isPayPhase: boolean; }) { const style = STATE_STYLE[booking.state]; const Icon = style.icon; const selected = booking.state === "SELECTED_FOR_BATCH"; const allocated = booking.state === "ALLOCATED"; const expired = booking.state === "EXPIRED"; // Green surface for the winners (allocated + selected); muted for the rest. const surfaceColor = allocated ? "edr-green" : selected ? "edr-green" : expired ? "red" : "gray"; const scorePct = scoreMax > 0 ? Math.max(4, Math.round((booking.priorityScore / scoreMax) * 100)) : 0; return ( {/* Rank medallion */} {booking.isGovernment ? ( ) : ( {rank} )} {booking.reference} {booking.isGovernment ? ( ) : null} {booking.company} {/* Priority score with a mini strength bar */} {booking.priorityScore} {/* Wagons */} {booking.wagons}w {/* State chip / pay countdown */} {selected && isPayPhase && booking.paymentDeadline ? ( ) : ( {style.label} )} {/* phase hint only used for the a11y title; keeps `phase` referenced */} ); } /** * Consecutive-run grouping of an already-ranked lane by boarding class: * government first, then each booking-window cycle (windowCycleNo is 0-based, * so cycle 0 renders as "1st cycle window"). rankBookings sorts gov → cycle * asc, so consecutive runs are exactly the cycle groups. */ type CycleGroup = { key: string; color: string; label: string; sub: string | null; items: BatchBoardBookingDetail[]; }; const CYCLE_COLORS = ["indigo", "cyan", "teal"]; const ordinal = (n: number) => n === 1 ? "1st" : n === 2 ? "2nd" : n === 3 ? "3rd" : `${n}th`; function groupMeta(b: BatchBoardBookingDetail): Omit { if (b.isGovernment) return { key: "gov", color: "grape", label: "Government", sub: "boards first" }; if (b.windowCycleNo == null) return { key: "none", color: "gray", label: "No cycle yet", sub: "contract not signed", }; const n = b.windowCycleNo + 1; return { key: `c${b.windowCycleNo}`, color: CYCLE_COLORS[b.windowCycleNo % CYCLE_COLORS.length], label: `${ordinal(n)} cycle window`, sub: n === 1 ? "booked in the first window" : "boards after earlier cycles", }; } function groupByCycle(items: BatchBoardBookingDetail[]): CycleGroup[] { const groups: CycleGroup[] = []; for (const b of items) { const meta = groupMeta(b); const last = groups[groups.length - 1]; if (last && last.key === meta.key) last.items.push(b); else groups.push({ ...meta, items: [b] }); } return groups; } /** Tinted wrapper card holding one cycle's ranked bookings. */ function CycleSection({ group, children, }: { group: CycleGroup; children: ReactNode; }) { const wagons = group.items.reduce((s, b) => s + b.wagons, 0); return ( {group.key === "gov" ? : } {group.label} {group.sub ? ( — {group.sub} ) : null} {group.items.length} booking{group.items.length === 1 ? "" : "s"} ·{" "} {wagons}w {children} ); } /** The capacity cut line drawn between "in the batch" and "waiting list". */ function CapacityDivider({ used, max }: { used: number; max: number | null }) { const full = max != null && used >= max; return ( Capacity line{max != null ? ` · ${used}/${max} wagons` : ` · ${used} wagons`} {full ? " · FULL" : ""} ); } // Memoized: mounted in a keep-mounted Tabs panel, so it re-renders with every // page render; both props keep their identity across unrelated page state // (React Query structural sharing + the page's useMemo'd bookings). export const PriorityTrackingTab = memo(function PriorityTrackingTab({ data, bookings, }: Props) { const phase = data.windowPhase; const isPayPhase = phase === "PAYMENT"; // Before the batch is committed (pre-window / open / doc-review) the real // selection doesn't exist yet — offer a simulated forecast of who WOULD board. // Default to it while it's live; let staff flip to the current live state. const forecastAvailable = forecastIsLive(phase); const [view, setView] = useState<"forecast" | "live">( forecastAvailable ? "forecast" : "live", ); const showForecast = forecastAvailable && view === "forecast"; // Rank exactly as the batch engine does: government first, then window cycle // (bookings only compete within the cycle they arrived in — an earlier cycle // boards before a later one regardless of score), then priority desc, then // oldest. Shared with the forecast sim so both views agree. const ranked = useMemo(() => rankBookings(bookings), [bookings]); const scoreMax = useMemo(() => maxScore(ranked), [ranked]); // Wagon-slot cap from the board DTO (derived from train length and the // shortest wagon type); null on legacy rows without a computable cap. const maxWagons: number | null = data.capacity.maxWagons ?? null; // Split the ranking at the capacity line: cumulative wagons of slot-occupying // bookings (allocated + selected + paid-waiting) up to the train's wagon cap. const capUsed = useMemo( () => ranked .filter((b) => OCCUPIES_SLOT.includes(b.state)) .reduce((sum, b) => sum + b.wagons, 0), [ranked], ); // Group for the lane layout. const lanes = useMemo(() => { const inBatch = ranked.filter((b) => OCCUPIES_SLOT.includes(b.state)); const waiting = ranked.filter( (b) => b.state === "READY" || b.state === "PENDING_CONTRACT", ); const expired = ranked.filter((b) => b.state === "EXPIRED"); return { inBatch, waiting, expired }; }, [ranked]); if (ranked.length === 0) { return ( No bookings on this schedule yet. ); } let rankNo = 0; const viewToggle = forecastAvailable ? ( setView(v as "forecast" | "live")} size="sm" radius="md" data={[ { value: "forecast", label: ( Forecast ), }, { value: "live", label: ( Live state ), }, ]} /> ) : null; if (showForecast) { return ( {viewToggle ? {viewToggle} : null} ); } return ( {viewToggle ? {viewToggle} : null} {/* Header: phase + capacity meter */} Priority ranking Government first, then booking window (earlier cycles board first), then rule-engine score, then earliest booked. {phase ? ( ) : null} {isPayPhase && data.paymentPhaseEndsAt ? ( ) : phase === "DOC_REVIEW" && data.docReviewEndsAt ? ( ) : phase === "OPEN" && data.windowClosesAt ? ( ) : null} {/* Capacity meter */} Wagon capacity used {data.capacity.allocatedWagons} allocated ·{" "} {capUsed} in batch {maxWagons != null ? ` · ${maxWagons} max` : ""} {/* Scale against the real wagon cap when the DTO carries one; fall back to the in-batch total on legacy rows without a computable cap. */} 0 ? Math.min( 100, (data.capacity.allocatedWagons / (maxWagons ?? capUsed)) * 100, ) : 0 } color="edr-green" /> 0 ? Math.min( 100, ((capUsed - data.capacity.allocatedWagons) / (maxWagons ?? capUsed)) * 100, ) : 0 } color="orange" /> {/* Phase banner explaining what's happening now */} {/* IN THE BATCH (green winners) — ranked */} {lanes.inBatch.length > 0 ? ( In the batch{" "} ({lanes.inBatch.length}) {groupByCycle(lanes.inBatch).map((g) => ( {g.items.map((b) => { rankNo += 1; return ( ); })} ))} ) : null} {/* WAITING LIST — ranked, below the line */} {lanes.waiting.length > 0 ? ( Waiting list{" "} ({lanes.waiting.length}) — next in line if a slot frees up {groupByCycle(lanes.waiting).map((g) => ( {g.items.map((b) => { rankNo += 1; return ( ); })} ))} ) : null} {/* EXPIRED */} {lanes.expired.length > 0 ? ( Expired{" "} ({lanes.expired.length}) — missed the payment window {lanes.expired.map((b) => ( ))} ) : null} ); }); /** Contextual banner describing the current window phase in plain language. */ function PhaseBanner({ phase }: { phase: string | null }) { const meta: Record = { OPEN: { color: "edr-green", icon: Clock, text: "Booking window OPEN — new bookings are ranked live as they arrive and get accepted.", }, DOC_REVIEW: { color: "yellow", icon: Hourglass, text: "Document review — staff accept/reject; un-accepted bookings expire when review ends, then the batch runs.", }, PAYMENT: { color: "blue", icon: Clock, text: "Payment window — selected bookings must pay before their countdown ends; unpaid slots pass to the waiting list.", }, PRE_WINDOW: { color: "gray", icon: Hourglass, text: "Window not open yet — bookings are pre-ranked and will compete when it opens.", }, CLOSED_FOR_DAY: { color: "gray", icon: Hourglass, text: "Window closed for the day — reopens for the next cycle if the train isn't full.", }, DONE: { color: "gray", icon: CheckCircle2, text: "Booking cycles finished for this train.", }, }; const m = phase ? meta[phase] : null; if (!m) return null; const Icon = m.icon; return ( {m.text} ); } export default PriorityTrackingTab;