import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { ActionIcon, Alert, Badge, Box, Button, Card, Group, Menu, Paper, RingProgress, Select, SimpleGrid, Skeleton, Stack, Text, ThemeIcon, Tooltip, } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; import { getDateRangePresets } from "@/components/common/dateRangePresets"; import { useDebouncedValue } from "@mantine/hooks"; import { AlertTriangle, ArrowDownWideNarrow, ArrowRight, ArrowUpNarrowWide, CalendarClock, CalendarDays, Eye, Inbox, MoreHorizontal, Package, Ruler, Train, TrainFront, Weight, } from "lucide-react"; import type { ColumnDef } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import FleetToolbar from "@/components/fleet/FleetToolbar"; import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; import { BookingPipeline, HeroChip, totalBookingCount, WindowPhasePill, WindowStatusPill, } from "@/components/trainScheduling/batchVisuals"; import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket"; import type { BatchBoardFilters, BatchBoardSchedule, BatchBoardSortField, TrainScheduleStatus, } from "@/types/trainScheduling"; const STATUS_BADGE: Record = { DRAFT: { color: "gray", label: "Draft" }, SCHEDULED: { color: "blue", label: "Scheduled" }, DISPATCHED: { color: "orange", label: "Dispatched" }, ARRIVED: { color: "green", label: "Arrived" }, CANCELLED: { color: "red", label: "Cancelled" }, }; function StatusBadge({ status }: { status: string }) { const meta = STATUS_BADGE[status] ?? { color: "gray", label: status }; return ( {meta.label} ); } const fmtTons = (n: number) => `${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`; const fmtMeters = (n: number) => `${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`; const fmtScheduleDate = (iso: string | null) => iso ? new Intl.DateTimeFormat("en-GB", { weekday: "short", day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "Africa/Addis_Ababa", }).format(new Date(iso)) + " EAT" : "No date"; const splitDate = (iso: string | null) => { if (!iso) return { day: "—", time: "" }; const date = new Date(iso); if (Number.isNaN(date.getTime())) return { day: "—", time: "" }; return { day: new Intl.DateTimeFormat("en-GB", { day: "2-digit", month: "short", year: "numeric", timeZone: "Africa/Addis_Ababa", }).format(date), time: new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "Africa/Addis_Ababa", }).format(date) + " EAT", }; }; /** Capacity ring color: gold normally, red once over capacity. */ function ringColor(pct: number) { if (pct >= 100) return "#fa5252"; return "#F2A516"; } /** Capacity ring that keeps the explicit allocated/max numbers underneath. */ function CapacityRing({ pct, label, current, max, }: { pct: number; label: string; current: string; max: string; }) { const clamped = Math.min(100, Math.max(0, pct)); const color = ringColor(pct); return ( {Math.round(pct)}% {label} } /> {current} of {max} ); } /** Small percent chip used in the table's capacity column. */ function CapacityChip({ icon: Icon, pct, text, }: { icon: typeof Weight; pct: number | null; text: string; }) { const over = pct != null && pct >= 100; return ( {pct != null ? `${Math.round(pct)}%` : "—"} {text} ); } /** Gross weight (wagon tare + cargo) against the locomotive's pull limit. */ function weightPctOf(s: BatchBoardSchedule) { return s.capacity.maxWeightTons && s.capacity.maxWeightTons > 0 ? (s.capacity.usedWeightTons / s.capacity.maxWeightTons) * 100 : null; } function lengthPctOf(s: BatchBoardSchedule) { return s.capacity.maxLengthMeters && s.capacity.maxLengthMeters > 0 ? (s.capacity.allocatedLengthMeters / s.capacity.maxLengthMeters) * 100 : null; } function wagonPctOf(s: BatchBoardSchedule) { return s.capacity.maxWagons && s.capacity.maxWagons > 0 ? (s.capacity.allocatedWagons / s.capacity.maxWagons) * 100 : null; } function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) { const navigate = useNavigate(); const { capacity, counts, locomotive } = schedule; const lengthPct = lengthPctOf(schedule); const weightPct = weightPctOf(schedule); const wagonPct = wagonPctOf(schedule); const totalBookings = totalBookingCount(counts); return ( navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`) } > {/* header */} {schedule.trainNumber ?? schedule.routeName ?? "Schedule"} {schedule.scheduleReference ?? "Freight schedule"} ·{" "} {schedule.status} {schedule.windowPhase ? ( ) : null} }> {fmtScheduleDate(schedule.scheduleDate)} {locomotive ? ( }> {locomotive.code} · {fmtTons(locomotive.maxPullWeightTons)} ) : null} {!locomotive ? ( } py={6} styles={{ message: { fontSize: 12 } }} > No locomotive assigned — wagon allocation cannot run. ) : null} {/* capacity: the three axes a train is limited by — gross weight, wagon slots, length */} {weightPct != null ? ( ) : null} {wagonPct != null ? ( ) : ( {capacity.allocatedWagons} Wagons allocated )} {lengthPct != null ? ( ) : null} {/* booking pipeline */} Booking pipeline {totalBookings} booking{totalBookings === 1 ? "" : "s"} {/* CTA */} ); } function CardSkeleton() { return ( ); } export default function BatchBoardPage() { const navigate = useNavigate(); // Live board: phase + batch-changed pushes invalidate the list query below. useBookingWindowSocket(); const { viewMode, setViewMode } = useFleetViewMode("batch-board"); const { pagination, setPagination } = usePagination({ pageSize: 12 }); const [search, setSearch] = useState(""); const [debouncedSearch] = useDebouncedValue(search, 300); const [statusFilter, setStatusFilter] = useState("ALL"); const [windowFilter, setWindowFilter] = useState("ALL"); const [sortBy, setSortBy] = useState("createdAt"); const [sortOrder, setSortOrder] = useState<"ASC" | "DESC">("DESC"); const [departureFrom, setDepartureFrom] = useState(null); const [departureTo, setDepartureTo] = useState(null); // Every knob maps straight onto the server-side batch-board query — the API // filters, searches, sorts and paginates; this page just renders the page. const filters = useMemo((): BatchBoardFilters => { const endOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999); return { page: pagination.pageIndex + 1, pageSize: pagination.pageSize, search: debouncedSearch.trim() || undefined, statuses: statusFilter === "ALL" ? undefined : [statusFilter as TrainScheduleStatus], bookingWindowStatus: windowFilter === "ALL" ? undefined : (windowFilter as "OPEN" | "FULL" | "CLOSED"), departureFrom: departureFrom ? departureFrom.toISOString() : undefined, departureTo: departureTo ? endOfDay(departureTo).toISOString() : undefined, sortBy, sortOrder, }; }, [ pagination, debouncedSearch, statusFilter, windowFilter, departureFrom, departureTo, sortBy, sortOrder, ]); // Any filter change restarts from the first page. useEffect(() => { setPagination((p) => ({ ...p, pageIndex: 0 })); }, [ debouncedSearch, statusFilter, windowFilter, departureFrom, departureTo, sortBy, sortOrder, setPagination, ]); const { data, isLoading, isError, isFetching, refetch } = useQuery({ ...api.trainScheduling.batchBoard.queryOptions({ input: { filters } }), // Real-time updates come from the booking-window socket (batch-board:changed // + PHASE pushes invalidate this query); 60s is only a self-heal safety net // for a missed emit. refetchInterval: 60_000, placeholderData: keepPreviousData, }); const schedules = data?.items ?? []; const total = data?.meta.total ?? 0; // The table footer expects at least one page even when the board is empty. const pageCount = Math.max(1, data?.meta.totalPages ?? 1); const summary = useMemo(() => { const openWindows = schedules.filter((s) => s.bookingWindowStatus === "OPEN").length; const totalBookings = schedules.reduce((sum, s) => sum + totalBookingCount(s.counts), 0); const totalWagons = schedules.reduce((sum, s) => sum + s.capacity.allocatedWagons, 0); return { openWindows, totalBookings, totalWagons }; }, [schedules]); const columns = useMemo((): ColumnDef[] => { const headerClassName = ruleEngineTable.headerCell; const cellClassName = ruleEngineTable.bodyCell; return [ { id: "train", header: "Train / Route", meta: { headerClassName, cellClassName }, cell: ({ row }) => ( {row.original.trainNumber ?? row.original.routeName ?? "Schedule"} {row.original.scheduleReference ? ( {row.original.scheduleReference} ) : null} ), }, { id: "date", header: "Departure", meta: { headerClassName, cellClassName }, cell: ({ row }) => { const { day, time } = splitDate(row.original.scheduleDate); return ( {day} {time || "—"} ); }, }, { id: "status", header: "Status", meta: { headerClassName, cellClassName }, cell: ({ row }) => , }, { id: "created", header: "Created", meta: { headerClassName, cellClassName }, cell: ({ row }) => { const { day, time } = splitDate(row.original.createdAt); return ( {day} {time || "—"} ); }, }, { id: "window", header: "Window", meta: { headerClassName, cellClassName }, cell: ({ row }) => , }, { id: "loco", header: "Locomotive", meta: { headerClassName, cellClassName }, cell: ({ row }) => row.original.locomotive ? ( {row.original.locomotive.code} {fmtTons(row.original.locomotive.maxPullWeightTons)} pull ) : ( No loco ), }, { id: "capacity", header: "Capacity", meta: { headerClassName, cellClassName }, cell: ({ row }) => { const { allocatedWagons, maxWagons } = row.original.capacity; const wagonPct = wagonPctOf(row.original); return ( {wagonPct != null ? ( ) : ( {allocatedWagons} wgn )} ); }, }, { id: "bookings", header: "Bookings", meta: { headerClassName, cellClassName }, cell: ({ row }) => { const total = totalBookingCount(row.original.counts); return ( {total} booking{total === 1 ? "" : "s"} ); }, }, { id: "actions", header: "", meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` }, cell: ({ row }) => ( e.stopPropagation()}> } onClick={() => navigate(`/dashboard/operations/batch-board/${row.original.scheduleId}`) } > View windows ), }, ]; }, [navigate]); const tableStatus = isLoading ? "loading" : isError ? "error" : "success"; return ( void refetch()}> Refresh } /> v && setWindowFilter(v)} data={[ { value: "ALL", label: "All windows" }, { value: "OPEN", label: "Open" }, { value: "FULL", label: "Full" }, { value: "CLOSED", label: "Closed" }, ]} w={140} styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} /> { setDepartureFrom(from ? new Date(from) : null); setDepartureTo(to ? new Date(to) : null); }} presets={getDateRangePresets()} clearable w={230} styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} />