import { useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Accordion, ActionIcon, Alert, Badge, Box, Button, Container, Group, Loader, Paper, SimpleGrid, Stack, Table, Tabs, Text, ThemeIcon, Title, Tooltip, } from "@mantine/core"; import { AlertTriangle, ArrowLeft, Boxes, CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, Clock, FileSignature, Hourglass, Layers, Package, PlayCircle, RefreshCw, Ruler, TrainFront, Weight, XCircle, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram"; import { TrainConsistView, CompositionBookingTabs } from "@/components/trainScheduling/compositionEditor"; import { BookingPipeline, HeroChip, totalBookingCount, WindowStatusPill, } from "@/components/trainScheduling/batchVisuals"; import { MiniRing, MiniSparkline } from "@/components/common/MiniGraph"; import type { OverviewAccent } from "@/components/overview/overview.styles"; import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals"; import { useBatchBoardDetail, useRunAllocation, useScheduleDetail, } from "@/hooks/trainScheduling/useTrainScheduling"; import { useToast } from "@/hooks/use-toast"; import type { BatchBoardBookingDetail, BatchBoardBookingState, BatchWindowGroup, BookingAllocationStatus, } from "@/types/trainScheduling"; const STATE_META: Record< BatchBoardBookingState, { label: string; color: string; icon: typeof CheckCircle2 } > = { ALLOCATED: { label: "Allocated", color: "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: "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 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} ); } function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) { if (!bookings.length) { return ( No bookings in this batch window. ); } const th = (label: string) => ( {label} ); return ( {th("Reference")} {th("Customer")} {th("Contract signed")} {th("Selected for batch")} {th("Capacity")} {th("Batch state")} {th("Wagon allocation")} {bookings.map((b) => ( {b.reference} {b.isGovernment ? ( Gov ) : null} {initials(b.company)} {b.company} {fmtDateTime(b.fullyExecutedAt)} EAT {b.selectedForBatchAt ? ( <> {fmtDateTime(b.selectedForBatchAt)} EAT {b.paymentDeadline ? ( Pay by {fmtDateTime(b.paymentDeadline)} EAT ) : null} ) : ( )} {b.wagons}w {fmtTons(b.weightTons)} ))}
); } function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) { const chips: Array<{ value: number; color: string; label: string }> = [ { value: counts.allocated, color: "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} ); } /** * Light stat card matching the overview look — keeps the explicit numbers. * Capacity cards (pct set) show a ring; count cards show an area/line trend. */ function StatCard({ icon: Icon, label, value, sub, pct, variant = "area", }: { icon: LucideIcon; label: string; value: ReactNode; sub?: string; pct?: number | null; variant?: "area" | "line"; }) { const ringAccent: OverviewAccent = pct == null ? "gold" : pct >= 100 ? "rose" : pct >= 85 ? "orange" : "gold"; return ( {value} {label} {sub ? ` · ${sub}` : ""} {pct != null ? ( {Math.round(pct)}% ) : null} {pct == null ? ( ) : null} ); } export default function BatchScheduleDetailPage() { const { scheduleId } = useParams<{ scheduleId: string }>(); const navigate = useNavigate(); const { toast } = useToast(); const { data, isLoading, isFetching, refetch } = useBatchBoardDetail(scheduleId); const runAllocation = useRunAllocation(scheduleId ?? ""); 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 = useScheduleDetail(scheduleId, "CONTAINER"); // Batch bookings by state for the composition side panel (payment / expired lists). const batchBookings = useMemo(() => { if (!data) return { awaitingPayment: [], expired: [] }; const all = [ ...data.windows.flatMap((w) => w.bookings), ...data.pendingContract.bookings, ]; return { awaitingPayment: all.filter((b) => b.state === "SELECTED_FOR_BATCH"), expired: all.filter((b) => b.state === "EXPIRED"), }; }, [data]); // 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 handleRunAllocation = () => { runAllocation .mutateAsync() .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 lengthPct = data.capacity.maxLengthMeters && data.capacity.maxLengthMeters > 0 ? (data.capacity.allocatedLengthMeters / data.capacity.maxLengthMeters) * 100 : null; const weightPct = data.capacity.maxWeightTons && data.capacity.maxWeightTons > 0 ? (data.capacity.usedWeightTons / data.capacity.maxWeightTons) * 100 : null; const totalBookings = totalBookingCount(data.counts); 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.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.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} {/* 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… )} ); }