import { useEffect, useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Accordion, ActionIcon, Alert, Badge, Box, Button, Group, Loader, Paper, Stack, Tabs, Text, ThemeIcon, Title, Tooltip, } from "@mantine/core"; import { AlertTriangle, ArrowLeft, ArrowLeftRight, Boxes, CalendarDays, CheckCircle2, ChevronLeft, ClipboardCheck, ChevronRight, Clock, FileSignature, Hourglass, Layers, Package, PlayCircle, RefreshCw, Ruler, TrainFront, Weight, XCircle, } from "lucide-react"; import { 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 { 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). */ function timeLabelOf(label: string): string { const idx = label.indexOf("·"); return idx >= 0 ? label.slice(idx + 1).trim() : label; } const EAT_TZ = "Africa/Addis_Ababa"; const dateKeyFmt = new Intl.DateTimeFormat("en-CA", { timeZone: EAT_TZ, year: "numeric", month: "2-digit", day: "2-digit", }); const dateLabelFmt = new Intl.DateTimeFormat("en-GB", { timeZone: EAT_TZ, weekday: "short", day: "2-digit", month: "short", }); /** EAT calendar date key for a window — prefers the API field, falls back to `start`. */ function windowDateKey(w: BatchWindowGroup): string { if (w.date) return w.date; if (w.start) return dateKeyFmt.format(new Date(w.start)); return "undated"; } /** Human day label for a window — prefers the API field, falls back to `start`. */ function windowDateLabel(w: BatchWindowGroup): string { if (w.dateLabel) return w.dateLabel; if (w.start) return dateLabelFmt.format(new Date(w.start)); return "Undated"; } function WindowAccordionItem({ window }: { window: BatchWindowGroup }) { const total = window.bookings.length; const hasIssues = window.bookings.some( (b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED", ); return ( {timeLabelOf(window.label)} {total ? `${total} booking${total === 1 ? "" : "s"}` : "Empty window"} {hasIssues ? ( } > Issues ) : null} ); } 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), refetchInterval: 30_000, }), ); 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), }), ); // 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]); // 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], ); // Group the flat window list into per-day sections (one per EAT calendar date). const dayGroups = useMemo(() => { if (!data) return []; const byDate = new Map< string, { date: string; dateLabel: string; windows: BatchWindowGroup[]; totalBookings: number; counts: BatchWindowGroup["counts"]; hasIssues: boolean; } >(); for (const w of data.windows) { const dateKey = windowDateKey(w); let group = byDate.get(dateKey); if (!group) { group = { date: dateKey, dateLabel: windowDateLabel(w), windows: [], totalBookings: 0, counts: { allocated: 0, selectedForBatch: 0, ready: 0, waiting: 0, expired: 0, pendingContract: 0, }, hasIssues: false, }; byDate.set(dateKey, group); } group.windows.push(w); group.totalBookings += w.bookings.length; group.counts.allocated += w.counts.allocated; group.counts.selectedForBatch += w.counts.selectedForBatch; group.counts.ready += w.counts.ready; group.counts.waiting += w.counts.waiting; group.counts.expired += w.counts.expired; group.counts.pendingContract += w.counts.pendingContract; group.hasIssues = group.hasIssues || w.bookings.some( (b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED", ); } return [...byDate.values()]; }, [data]); // Windows with bookings open by default (inside an expanded day). const openWindowKeys = useMemo( () => data ? data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key) : [], [data], ); const todayEat = useMemo( () => new Intl.DateTimeFormat("en-CA", { timeZone: "Africa/Addis_Ababa", year: "numeric", month: "2-digit", day: "2-digit", }).format(new Date()), [], ); // Date-stepper: which day is currently shown. Default to today, else the first // day with bookings, else the first day. Keep the selection if still valid. const [selectedDate, setSelectedDate] = useState(null); const [activeTab, setActiveTab] = useState("overview"); const [selectedBookingId, setSelectedBookingId] = useState( null, ); useEffect(() => { if (!dayGroups.length) return; if (selectedDate && dayGroups.some((d) => d.date === selectedDate)) return; const preferred = dayGroups.find((d) => d.date === todayEat) ?? dayGroups.find((d) => d.totalBookings > 0) ?? dayGroups[0]; setSelectedDate(preferred.date); }, [dayGroups, selectedDate, todayEat]); const selectedIndex = Math.max( 0, dayGroups.findIndex((d) => d.date === selectedDate), ); const selectedDay = dayGroups[selectedIndex]; 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 Train Composition{" "} {scheduleDetailQuery.data?.trainSet?.wagons && scheduleDetailQuery.data.trainSet.wagons.length > 0 && `(${scheduleDetailQuery.data.trainSet.wagons.length})`} {data.trainNumber ?? data.routeName ?? "Schedule"} {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 */} Batch windows (EAT) 3-hour windows for every day from when the booking window opened through the departure date. Bookings appear under the date their contract was signed — open a day to see its windows. {dayGroups.length && selectedDay ? ( <> {/* Date stepper — page back/forward through each day in the range */} setSelectedDate( dayGroups[selectedIndex - 1]?.date ?? null, ) } > {selectedDay.dateLabel} {selectedDay.date === todayEat ? ( Today ) : null} {selectedDay.totalBookings ? `${selectedDay.totalBookings} booking${selectedDay.totalBookings === 1 ? "" : "s"} · ${selectedDay.windows.length} windows` : `${selectedDay.windows.length} windows · no bookings`} = dayGroups.length - 1} onClick={() => setSelectedDate( dayGroups[selectedIndex + 1]?.date ?? null, ) } > Day {selectedIndex + 1} of {dayGroups.length} {selectedDay.hasIssues ? ( } > Issues ) : null} {selectedDay.windows.map((window) => ( ))} ) : ( No batch windows for this schedule. )} {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… )} ); }