import { useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Accordion, Alert, Badge, Box, Button, Group, Loader, Paper, Stack, Tabs, Text, ThemeIcon, Title, Tooltip, } from "@mantine/core"; import { AlertTriangle, ArrowLeft, ArrowLeftRight, CalendarDays, CheckCircle2, ClipboardCheck, Clock, FileSignature, Hash, Hourglass, Layers, Package, PlayCircle, RefreshCw, Ruler, TrainFront, Trophy, Weight, XCircle, } from "lucide-react"; import { CountdownTimer, DataTable, type ColumnDef } from "@edr/ui-common"; import { KpiStrip, PageContainer } from "@/components/page"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram"; import { TrainConsistView, CompositionBookingTabs, } from "@/components/trainScheduling/compositionEditor"; import { BookingPipeline, HeroChip, totalBookingCount, WindowPhasePill, WindowStatusPill, } from "@/components/trainScheduling/batchVisuals"; import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals"; import { PriorityTrackingTab } from "@/components/trainScheduling/PriorityTrackingTab"; import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket"; import { BookingsManager } from "./BookingsManager"; import { useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import type { BatchBoardBookingDetail, BatchBoardBookingState, BatchBoardScheduleDetail, BatchWindowGroup, BookingAllocationStatus, } from "@/types/trainScheduling"; const STATE_META: Record< BatchBoardBookingState, { label: string; color: string; icon: typeof CheckCircle2 } > = { ALLOCATED: { label: "Allocated", color: "edr-green", icon: CheckCircle2 }, SELECTED_FOR_BATCH: { label: "Selected for batch", color: "orange", icon: Clock, }, READY: { label: "Ready for batch", color: "teal", icon: Hourglass }, WAITING: { label: "Paid · waiting", color: "blue", icon: Hourglass }, PENDING_CONTRACT: { label: "Pending contract", color: "gray", icon: Hourglass, }, EXPIRED: { label: "Expired", color: "red", icon: XCircle }, }; const ALLOC_META: Record< BookingAllocationStatus, { label: string; color: string } > = { ASSIGNED: { label: "Wagons assigned", color: "edr-green" }, NOT_ATTEMPTED: { label: "Not allocated", color: "gray" }, DEFERRED: { label: "Deferred", color: "orange" }, FAILED: { label: "Allocation failed", color: "red" }, }; const fmtTons = (n: number) => `${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`; const fmtMeters = (n: number) => `${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`; const fmtDateTime = (iso: string | null) => iso ? new Intl.DateTimeFormat("en-GB", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "Africa/Addis_Ababa", }).format(new Date(iso)) : "—"; const eatDayFmt = new Intl.DateTimeFormat("en-CA", { timeZone: "Africa/Addis_Ababa", year: "numeric", month: "2-digit", day: "2-digit", }); /** "11:00 EAT" if the timestamp falls on today (EAT), else "05 Jun, 11:00 EAT". */ const fmtPhaseTime = (iso: string) => { const date = new Date(iso); const time = new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "Africa/Addis_Ababa", }).format(date); if (eatDayFmt.format(date) === eatDayFmt.format(new Date())) { return `${time} EAT`; } const day = new Intl.DateTimeFormat("en-GB", { day: "2-digit", month: "short", timeZone: "Africa/Addis_Ababa", }).format(date); return `${day}, ${time} EAT`; }; /** Countdown label for the current booking-cycle phase, e.g. "Closes 11:00 EAT". */ function phaseCountdown(data: BatchBoardScheduleDetail): string | null { switch (data.windowPhase) { case "PRE_WINDOW": return data.windowOpensAt ? `Opens ${fmtPhaseTime(data.windowOpensAt)}` : null; case "OPEN": return data.windowClosesAt ? `Closes ${fmtPhaseTime(data.windowClosesAt)}` : null; case "DOC_REVIEW": return data.docReviewEndsAt ? `Doc review ends ${fmtPhaseTime(data.docReviewEndsAt)}` : null; case "PAYMENT": return data.paymentPhaseEndsAt ? `Payment ends ${fmtPhaseTime(data.paymentPhaseEndsAt)}` : null; case "CLOSED_FOR_DAY": return data.windowOpensAt ? `Reopens ${fmtPhaseTime(data.windowOpensAt)}` : null; default: return null; } } const initials = (name: string) => name .split(/\s+/) .filter(Boolean) .slice(0, 2) .map((w) => w[0]) .join("") .toUpperCase() || "?"; function StateBadge({ state }: { state: BatchBoardBookingState }) { const meta = STATE_META[state]; const Icon = meta.icon; return ( } > {meta.label} ); } function AllocationBadge({ status, issue, }: { status: BookingAllocationStatus; issue: string | null; }) { const meta = ALLOC_META[status]; const badge = ( {meta.label} ); if (!issue) return badge; return ( {badge} ); } const bookingCellMeta = { headerClassName: ruleEngineTable.headerCell, cellClassName: ruleEngineTable.bodyCell, }; const BOOKING_COLUMNS: ColumnDef[] = [ { id: "reference", header: "Reference", meta: bookingCellMeta, cell: ({ row }) => { const b = row.original; return ( {b.reference} {b.isGovernment ? ( Gov ) : null} {b.consolidationPartnerRef ? ( } style={{ textTransform: "none" }} > shared wagon · {b.consolidationPartnerRef} ) : null} ); }, }, { id: "customer", header: "Customer", meta: bookingCellMeta, cell: ({ row }) => { const b = row.original; return ( {initials(b.company)} {b.company} ); }, }, { id: "contractSigned", header: "Contract signed", meta: bookingCellMeta, cell: ({ row }) => ( {fmtDateTime(row.original.fullyExecutedAt)} EAT ), }, { id: "selectedForBatch", header: "Selected for batch", meta: bookingCellMeta, cell: ({ row }) => { const b = row.original; if (!b.selectedForBatchAt) { return ( ); } return ( <> {fmtDateTime(b.selectedForBatchAt)} EAT {b.paymentDeadline ? ( Pay by {fmtDateTime(b.paymentDeadline)} EAT ) : null} ); }, }, { id: "capacity", header: "Capacity", meta: bookingCellMeta, cell: ({ row }) => { const b = row.original; return ( {b.wagons}w {fmtTons(b.weightTons)} ); }, }, { id: "state", header: "Batch state", meta: bookingCellMeta, cell: ({ row }) => , }, { id: "allocation", header: "Wagon allocation", meta: bookingCellMeta, cell: ({ row }) => ( ), }, ]; function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) { return ( ); } function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) { const chips: Array<{ value: number; color: string; label: string }> = [ { value: counts.allocated, color: "edr-green", label: "allocated" }, { value: counts.selectedForBatch, color: "orange", label: "selected" }, { value: counts.ready, color: "teal", label: "ready" }, { value: counts.waiting, color: "blue", label: "waiting" }, { value: counts.expired, color: "red", label: "expired" }, ].filter((c) => c.value > 0); return ( {chips.map((c) => ( {c.value} ))} ); } /** "05 Jun 2026 · 06:00 – 09:00 EAT" → "06:00 – 09:00 EAT" (date lives in the day header). */ const EAT_TZ = "Africa/Addis_Ababa"; const dateLabelFmt = new Intl.DateTimeFormat("en-GB", { timeZone: EAT_TZ, weekday: "short", day: "2-digit", month: "short", }); const timeFmt = new Intl.DateTimeFormat("en-GB", { timeZone: EAT_TZ, hour: "2-digit", minute: "2-digit", hour12: false, }); interface ScheduleWindow { windowPhase: BatchBoardScheduleDetail["windowPhase"]; bookingWindowStatus: string; windowOpensAt: string | null; windowClosesAt: string | null; docReviewEndsAt: string | null; paymentPhaseEndsAt: string | null; bookingCycleNo?: number; } /** * Deadline + label for the phase the schedule's booking window is currently in — * the SAME phases the customer sees on the portal: pre-window (opens) → open * (closes) → document review → payment. `expiredText` names the next step so a * lapsed deadline reads as a handover, not a bare "Expired". */ function windowPhaseCountdown( w: ScheduleWindow, ): { label: string; deadline: string; expiredText: string } | null { switch (w.windowPhase) { case "PRE_WINDOW": return w.windowOpensAt ? { label: "Booking opens in", deadline: w.windowOpensAt, expiredText: "Booking opening now…" } : null; case "OPEN": return w.windowClosesAt ? { label: "Window closes in", deadline: w.windowClosesAt, expiredText: "Document review starting…" } : null; case "DOC_REVIEW": return w.docReviewEndsAt ? { label: "Document review ends in", deadline: w.docReviewEndsAt, expiredText: "Payment starting…" } : null; case "PAYMENT": return w.paymentPhaseEndsAt ? { label: "Payment window ends in", deadline: w.paymentPhaseEndsAt, expiredText: "Payment window closing…" } : null; default: return null; } } /** One phase row: label + its clock time (or "—" when unset). */ function PhaseTimeRow({ label, iso, active, }: { label: string; iso: string | null; active: boolean; }) { return ( {label} {iso ? `${timeFmt.format(new Date(iso))} EAT` : "—"} ); } /** * The schedule's REAL booking window — the exact same window the customer sees on * the portal (frozen open/close from the schedule's own snapshot + the post-close * document-review and payment phases), with a live countdown to the current phase. * Replaces the old theoretical "3-hour windows across every day" projection. */ function ScheduleWindowPanel({ window: w }: { window: ScheduleWindow }) { const phase = w.windowPhase; const cd = windowPhaseCountdown(w); const open = phase === "OPEN" && w.bookingWindowStatus === "OPEN"; const openDay = w.windowOpensAt ? dateLabelFmt.format(new Date(w.windowOpensAt)) : null; return ( {phase ? ( ) : null} {openDay ? ( Booking day · {openDay} ) : null} {cd ? ( ) : null} ); } /** EAT calendar date key for a window — prefers the API field, falls back to `start`. */ export default function BatchScheduleDetailPage() { const { scheduleId } = useParams<{ scheduleId: string }>(); const navigate = useNavigate(); const { toast } = useToast(); const { data, isLoading, isFetching, refetch } = useQuery( api.trainScheduling.batchBoardDetail.queryOptions({ input: { scheduleId: scheduleId ?? "" }, enabled: Boolean(scheduleId), // Poll fast while a window cycle is actively moving (open / doc-review / // payment) so the priority ranking + pay countdowns stay live; back off to // 30s once the cycle is idle (pre-window / closed / done). refetchInterval: (query) => { const phase = (query.state.data as BatchBoardScheduleDetail | undefined) ?.windowPhase; return phase === "OPEN" || phase === "DOC_REVIEW" || phase === "PAYMENT" ? 5_000 : 30_000; }, }), ); // Keep the board in sync with server-pushed window-phase transitions too // (invalidates the batch-board list + patches window carousels). useBookingWindowSocket(Boolean(scheduleId)); const runAllocation = useMutation( api.trainScheduling.runAllocation.mutationOptions(), ); const completeDocReview = useMutation( api.trainScheduling.completeDocReview.mutationOptions(), ); const hasAssignedWagons = useMemo( () => Boolean( data?.windows.some((w) => w.bookings.some((b) => b.allocationStatus === "ASSIGNED"), ) || data?.pendingContract.bookings.some( (b) => b.allocationStatus === "ASSIGNED", ), ), [data], ); const scheduleDetailQuery = useQuery( api.trainScheduling.scheduleDetail.queryOptions({ input: { id: scheduleId ?? "", freightType: "CONTAINER" }, enabled: Boolean(scheduleId), // Composition data only changes through mutations, which invalidate the // whole train-scheduling root — no need to refetch on remounts in between. staleTime: 5 * 60_000, }), ); // Every booking on this schedule, flattened across windows + pending-contract, // de-duplicated (a booking only appears once). Feeds the management table. const allBookings = useMemo(() => { if (!data) return [] as BatchBoardBookingDetail[]; const merged = [ ...data.windows.flatMap((w) => w.bookings), ...data.pendingContract.bookings, ]; const byId = new Map(); for (const b of merged) if (!byId.has(b.id)) byId.set(b.id, b); return [...byId.values()]; }, [data]); // All bookings that fall inside the schedule's booking window (every window // cycle, flattened) — the window is one booking day, so these belong to the // single window panel above. const windowBookings = useMemo( () => (data?.windows ?? []).flatMap((w) => w.bookings), [data?.windows], ); const windowCounts = useMemo(() => { const counts = { allocated: 0, selectedForBatch: 0, ready: 0, waiting: 0, expired: 0, pendingContract: 0, }; for (const b of windowBookings) { if (b.state === "ALLOCATED") counts.allocated += 1; else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1; else if (b.state === "READY") counts.ready += 1; else if (b.state === "WAITING") counts.waiting += 1; else if (b.state === "EXPIRED") counts.expired += 1; else counts.pendingContract += 1; } return counts; }, [windowBookings]); // Batch bookings by state for the composition side panel (payment / expired lists). const batchBookings = useMemo(() => { const all = allBookings; return { awaitingPayment: all.filter((b) => b.state === "SELECTED_FOR_BATCH"), expired: all.filter((b) => b.state === "EXPIRED"), }; }, [allBookings]); const bookingsReadOnly = useMemo( () => ["DISPATCHED", "ARRIVED"].includes(data?.status ?? ""), [data?.status], ); const [activeTab, setActiveTab] = useState("overview"); const [selectedBookingId, setSelectedBookingId] = useState( null, ); const handleCompleteDocReview = () => { completeDocReview .mutateAsync(scheduleId ?? "") .then(() => { toast({ title: "Document review complete", description: "Batch is running for this route-day group", }); void refetch(); }) .catch(() => { toast({ title: "Could not complete document review", variant: "destructive", }); }); }; const handleRunAllocation = () => { runAllocation .mutateAsync({ scheduleId: scheduleId ?? "" }) .then((result) => { const failed = result.issues.filter( (i) => i.status === "FAILED", ).length; const deferred = result.deferred.length; toast({ title: "Allocation run complete", description: failed || deferred ? `${result.assignedBookingIds.length} assigned · ${deferred} deferred · ${failed} failed` : `${result.assignedBookingIds.length} booking(s) assigned to wagons`, variant: failed ? "destructive" : "default", }); void refetch(); }) .catch(() => { toast({ title: "Allocation failed", variant: "destructive" }); }); }; if (isLoading || !data) { return ( ); } const totalBookings = totalBookingCount(data.counts); const countdown = phaseCountdown(data); return ( Overview } > Priority Tracking{" "} {allBookings.length > 0 && `(${allBookings.length})`} Train Composition{" "} {scheduleDetailQuery.data?.trainSet?.wagons && scheduleDetailQuery.data.trainSet.wagons.length > 0 && `(${scheduleDetailQuery.data.trainSet.wagons.length})`} {data.trainNumber ?? data.routeName ?? "Schedule"} {data.scheduleReference ? ( }> {data.scheduleReference} ) : null} {data.windowPhase ? ( ) : null} {data.status} }> {data.scheduleDate ? new Intl.DateTimeFormat("en-GB", { weekday: "short", day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "Africa/Addis_Ababa", }).format(new Date(data.scheduleDate)) + " EAT" : "No date"} {data.locomotive ? ( }> Loco {data.locomotive.code} ·{" "} {fmtTons(data.locomotive.maxPullWeightTons)} ·{" "} {data.locomotive.maxTrainLengthMeters} m ) : null} {data.windowPhase ? ( }> Cycle {data.bookingCycleNo} {countdown ? ` · ${countdown}` : ""} ) : null} {data.windowPhase === "DOC_REVIEW" ? ( ) : null} {!data.locomotive ? ( }> No locomotive assigned — wagon allocation cannot run. ) : null} {/* Booking pipeline */} Booking pipeline {totalBookings} booking{totalBookings === 1 ? "" : "s"} {data.allocationViolations.length ? ( } title="Allocation constraints" > {data.allocationViolations.map((v) => ( {v} ))} ) : null} {/* Manage bookings — search, filter, remove / re-assign (bulk too) */} Manage bookings Search and filter every booking on this train. Remove an allocated booking to free its wagons, or re-assign one that is not yet allocated — individually or in bulk. void refetch()} readOnly={bookingsReadOnly} /> {/* Batch windows */} Booking window (EAT) The schedule's real booking window — the same window and phase timings the customer sees on the portal. Bookings in the window are listed below. {windowBookings.length ? ( Bookings in this window ) : ( No bookings in this window yet. )} {data.pendingContract.bookings.length ? ( Pending contract Contract not signed yet — not in any window {data.pendingContract.bookings.length} booking {data.pendingContract.bookings.length === 1 ? "" : "s"} ) : null} {/* Train composition diagram */} {hasAssignedWagons && scheduleDetailQuery.data ? ( ) : null} {scheduleDetailQuery.data && scheduleDetailQuery.data.trainSet ? ( ) : ( Loading train composition… )} ); }