import { Alert, Badge, Box, Button, Checkbox, Group, List, Loader, Modal, Paper, RingProgress, Stack, Tabs, Text, ThemeIcon, Title, } from "@mantine/core"; import { isAxiosError } from "axios"; import { AlertTriangle, ArrowLeft, CalendarClock, CheckCircle2, Clock, Container as ContainerIcon, Eye, FileText, History as HistoryIcon, LayoutGrid, Navigation, Package, PackageCheck, Route as RouteIcon, Ruler, Send, Train, Weight, Workflow as WorkflowIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { KpiStrip, PageContainer } from "@/components/page"; import { autoFillPlacements, mergePlacementsWithSaved, placementsFromScheduleWagons, validateLocalPlacements, } from "@/components/trainScheduling/containerPlacement.util"; import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid"; import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel"; import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel"; import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel"; // import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal"; // import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel"; import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep"; import { SwitchGovernmentBookingModal } from "@/components/trainScheduling/SwitchGovernmentBookingModal"; import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWorkspacePanel"; import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; import { RouteCorridor, SegmentOccupancyStrip, StatusPill, scheduleBrand, } from "@/components/trainScheduling/scheduleVisuals"; import { PreviewSummary, ScheduleWarningsAlert, } from "@/components/trainScheduling/ScheduleWarningsAlert"; import { Train3DVisualization } from "@/components/trainScheduling/Train3DVisualization"; import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram"; import { TrainConsistView } from "@/components/trainScheduling/compositionEditor"; import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid"; import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep"; import { openPdfBlob } from "@/components/warehouses/pdf"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket"; import { api } from "@/services/api"; import { trainSchedulingService } from "@/services/trainScheduling.service"; import { useToast } from "@/hooks/use-toast"; import type { ContainerPlacement, EligibleContainerBooking, FreightType, TrainSchedulePreviewResponse, } from "@/types/trainScheduling"; const parseError = (error: unknown, fallback: string) => { if (isAxiosError(error)) { const data = error.response?.data as Record | undefined; const message = data?.message; if (Array.isArray(message)) return message.join(", "); if (typeof message === "string") return message; const violations = data?.violations; if (Array.isArray(violations)) return violations.join(", "); } return fallback; }; export default function TrainScheduleV2DetailPage() { const { scheduleId } = useParams<{ scheduleId: string }>(); const { toast } = useToast(); const [activeStep, setActiveStep] = useState(0); const [selectedBookingIds, setSelectedBookingIds] = useState([]); const [forceAssign, setForceAssign] = useState(false); const [previewResult, setPreviewResult] = useState(null); const [containerPlacements, setContainerPlacements] = useState([]); const [maintenanceOpen, setMaintenanceOpen] = useState(false); const [windowSettingsOpen, setWindowSettingsOpen] = useState(false); const [gatepassSecuredAt, setGatepassSecuredAt] = useState(""); const [gatepassReference, setGatepassReference] = useState(""); const [gatepassFileUrl, setGatepassFileUrl] = useState(""); const [gatepassNotes, setGatepassNotes] = useState(""); const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false); const [switchTarget, setSwitchTarget] = useState(null); const [visualization3DOpen, setVisualization3DOpen] = useState(false); const autoPreviewedRef = useRef(false); const detailQuery = useQuery( api.trainScheduling.scheduleDetail.queryOptions({ input: { id: scheduleId ?? "" }, enabled: Boolean(scheduleId), // Live phase updates come from the booking-window socket (PHASE pushes // invalidate this query); 60s is the self-heal net for a missed emit so // the workspace countdown never freezes on an expired phase. refetchInterval: 60_000, }), ); useBookingWindowSocket(Boolean(scheduleId)); const schedule = detailQuery.data; const freightType: FreightType | undefined = schedule?.freightType as FreightType | undefined; const isDjiboutiPort = (value?: string | null) => ["DJIBOUTI", "DORALEH", "DMP", "DCT", "NAGAD"].some((token) => (value ?? "").toUpperCase().includes(token), ); const gatepassApplies = Boolean( schedule && ((schedule.direction === "IMPORT" && isDjiboutiPort(`${schedule.originStation?.code ?? ""} ${schedule.originStation?.label ?? ""}`)) || (schedule.direction === "EXPORT" && isDjiboutiPort(`${schedule.destinationStation?.code ?? ""} ${schedule.destinationStation?.label ?? ""}`))), ); const gatepassQuery = useQuery({ queryKey: ["train-scheduling", "gatepass", scheduleId], queryFn: () => trainSchedulingService.getImportDjiboutiOperation(scheduleId as string), enabled: Boolean(scheduleId && gatepassApplies), }); const gatepassSecured = gatepassQuery.data?.gatepassStatus === "SECURED"; const secureGatepass = useMutation({ mutationFn: () => trainSchedulingService.grantImportDjiboutiGatepass(scheduleId as string, { securedAt: gatepassSecuredAt ? new Date(gatepassSecuredAt).toISOString() : undefined, reference: gatepassReference.trim() || undefined, fileUrl: gatepassFileUrl.trim() || undefined, notes: gatepassNotes.trim() || undefined, }), onSuccess: () => { toast({ title: "Gate pass secured" }); void gatepassQuery.refetch(); }, onError: (error) => { toast({ title: "Gate pass failed", description: parseError(error, "Could not secure gate pass"), variant: "destructive", }); }, }); // Superseded by the Workspace tab Load/Unload toggle — see commented // "Import loading confirmation" card below. // const importLoadingQuery = useQuery( // api.trainScheduling.importLoadingBookings.queryOptions({ // input: { id: scheduleId ?? "" }, // enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"), // }), // ); const eligibleFilters = useMemo( () => schedule ? { originStationId: schedule.originStation?.id, destinationStationId: schedule.destinationStation?.id, // Only this schedule's own bookings are eligible — same rule as the auto batch. trainScheduleId: scheduleId, } : undefined, [schedule, scheduleId], ); const eligibleFreightType = freightType === "CONTAINER" || freightType === "BULK" ? freightType : undefined; const eligibleQuery = useQuery( api.trainScheduling.eligibleBookings.queryOptions({ input: { filters: eligibleFilters, freightType: eligibleFreightType }, enabled: Boolean(schedule), }), ); const preview = useMutation(api.trainScheduling.preview.mutationOptions()); const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions()); const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions()); const switchGov = useMutation(api.trainScheduling.switchGovernmentBooking.mutationOptions()); const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions()); const downloadMarshalling = useMutation({ mutationFn: ({ id, direction, variant }: { id: string; direction?: string | null; variant?: "INTERCITY" }) => variant === "INTERCITY" ? trainSchedulingService.downloadIntercityMarshallingDocument(id) : direction === "EXPORT" ? trainSchedulingService.downloadExportLoadListDocument(id) : trainSchedulingService.downloadImportDjiboutiLoadListDocument(id), }); useEffect(() => { const operation = gatepassQuery.data; if (!operation) return; const secured = operation.gatepassSecuredAt ?? operation.gatepassGrantedAt; setGatepassSecuredAt(secured ? new Date(secured).toISOString().slice(0, 16) : ""); setGatepassReference(operation.documents?.GATE_PASS?.reference ?? ""); setGatepassFileUrl(operation.documents?.GATE_PASS?.fileUrl ?? ""); setGatepassNotes(operation.documents?.GATE_PASS?.notes ?? operation.notes ?? ""); }, [gatepassQuery.data]); const assignedIds = useMemo( () => (schedule?.bookings ?? []).map((b) => b.id), [schedule?.bookings], ); const allSelectedIds = useMemo(() => { const merged = new Set([...assignedIds, ...selectedBookingIds]); return [...merged]; }, [assignedIds, selectedBookingIds]); const containerUnits = previewResult?.containerUnits ?? []; const containerSlots = previewResult?.containerSlotSequenceNos ?? []; // Container-number placement step removed — the customer enters container // numbers when booking, so scheduling skips straight from the wagon plan to // finalize. Steps: select bookings → review wagons → finalize. const hasContainerStep = false; const displayWagonPlan = useMemo(() => { const savedWagons = schedule?.trainSet?.wagons ?? []; // Map each slot to its reserved physical wagon number (from the wagon table) so the // plan shows real wagon ids (e.g. WGN-DEMO-001) instead of generic "Wagon #1". const physicalBySeq = new Map( savedWagons.map((w) => [w.sequenceNo, w.physicalWagonNumber ?? null]), ); if (previewResult?.wagonPlan?.length) { return previewResult.wagonPlan.map((slot) => ({ ...slot, physicalWagonNumber: physicalBySeq.get(slot.sequenceNo) ?? null, })); } if (savedWagons.length) return savedWagons; return []; }, [previewResult?.wagonPlan, schedule?.trainSet?.wagons]); // EXPORT schedules render the consist back-to-front (the train turns around // for the return run) — DISPLAY ONLY: stored sequenceNos, allocations, // documents, and the adjust-consist / placement flows keep the as-built order. const isExportDisplay = schedule?.direction === "EXPORT"; const displayWagonPlanOriented = useMemo( () => (isExportDisplay ? [...displayWagonPlan].reverse() : displayWagonPlan), [displayWagonPlan, isExportDisplay], ); const runPreview = useCallback( async (options?: { silent?: boolean; advanceStep?: boolean }) => { if (!schedule || !scheduleId) return null; if (!allSelectedIds.length) { if (!options?.silent) { toast({ title: "Select at least one booking", variant: "destructive" }); } return null; } const originStationId = schedule.originStation?.id; const destinationStationId = schedule.destinationStation?.id; if (!originStationId || !destinationStationId) { if (!options?.silent) { toast({ title: "Schedule missing origin or destination", variant: "destructive" }); } return null; } try { const result = await preview.mutateAsync({ freightType, payload: { bookingIds: allSelectedIds, scheduleDate: schedule.scheduledDepartureDate, originStationId, destinationStationId, targetScheduleId: scheduleId, }, }); setPreviewResult(result); if (result.containerUnits?.length && result.containerSlotSequenceNos?.length) { const autoFilled = autoFillPlacements( result.containerUnits, result.containerSlotSequenceNos, ); const saved = schedule.trainSet?.wagons ? placementsFromScheduleWagons(schedule.trainSet.wagons) : []; setContainerPlacements( saved.length ? mergePlacementsWithSaved(autoFilled, saved) : autoFilled, ); } else { setContainerPlacements([]); } if (!options?.silent) { if (!result.valid) { toast({ title: "Preview has violations", variant: "destructive" }); } else if (options?.advanceStep !== false) { setActiveStep(1); } } return result; } catch (err) { if (!options?.silent) { toast({ title: "Preview failed", description: parseError(err, "Could not preview"), variant: "destructive", }); } return null; } }, [allSelectedIds, freightType, preview, schedule, scheduleId, toast], ); useEffect(() => { if (!schedule || !scheduleId || autoPreviewedRef.current) return; if (!assignedIds.length) return; autoPreviewedRef.current = true; void runPreview({ silent: true, advanceStep: false }); }, [assignedIds.length, runPreview, schedule, scheduleId]); const savedPlacementsFromSchedule = useMemo( () => schedule?.trainSet?.wagons ? placementsFromScheduleWagons(schedule.trainSet.wagons) : [], [schedule?.trainSet?.wagons], ); useEffect(() => { if (!containerUnits.length || !containerSlots.length) return; setContainerPlacements((current) => { if (current.length && current.some((p) => p.containerNumber?.trim())) { return current; } const autoFilled = autoFillPlacements(containerUnits, containerSlots); if (savedPlacementsFromSchedule.length) { return mergePlacementsWithSaved(autoFilled, savedPlacementsFromSchedule); } if (current.length) return current; return autoFilled; }); }, [containerUnits, containerSlots, savedPlacementsFromSchedule]); if (detailQuery.isLoading) { return ( ); } if (!schedule || !scheduleId) { return ( Schedule not found ); } // All locomotives pulling the train (≥2), falling back to the legacy single loco. const locomotives = schedule.trainSet?.locomotives && schedule.trainSet.locomotives.length > 0 ? schedule.trainSet.locomotives : schedule.trainSet?.locomotive ? [schedule.trainSet.locomotive] : []; const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status); const canDispatch = schedule.status === "SCHEDULED"; // Dispatch readiness: bookings with no wagon, and wagon-loaded bookings whose // cargo staff never marked loaded. Both are warnings, not blockers — staff can // still dispatch after confirming. const dispatchBookings = schedule.bookings ?? []; const unassignedCount = dispatchBookings.filter((b) => !b.wagonAssigned).length; const unloadedCount = dispatchBookings.filter( (b) => b.wagonAssigned && (b.loadingStatus ?? "UNLOADED") !== "LOADED", ).length; // Intercity ride-alongs load through the journey flow (Load at their origin // yard), not the workspace toggle — dispatching before that leaves paid cargo // stranded on the platform while its train departs. const intercityNotLoadedCount = dispatchBookings.filter( (b) => b.tradeDirection === "DOMESTIC" && !b.loadedAt && !["IN_TRANSIT", "COMPLETED"].includes(b.status ?? ""), ).length; // No loading hard-block: bookings may board mid-corridor, so loading happens // per yard from the track page's log-pass flow. Everything below is advisory. const hasDispatchWarnings = unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0; const finalizeStep = hasContainerStep ? 3 : 2; const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status); const canPrintMarshalling = (schedule.direction === "IMPORT" || schedule.direction === "EXPORT") && ["DISPATCHED", "ARRIVED"].includes(schedule.status); const openMarshallingDocument = async (options?: { title?: string; successDescription?: string; errorTitle?: string; variant?: "INTERCITY"; }) => { const pdfWindow = window.open("", "_blank"); try { const blob = await downloadMarshalling.mutateAsync({ id: scheduleId, direction: schedule.direction, variant: options?.variant, }); const prefix = options?.variant === "INTERCITY" ? "intercity-marshalling" : schedule.direction === "EXPORT" ? "export-marshalling" : "import-marshalling"; const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`; const opened = openPdfBlob(blob, filename, pdfWindow); toast({ title: options?.title ?? "Marshalling document ready", description: options?.successDescription ?? (opened ? "The PDF opened in a browser tab for printing or saving." : "The browser blocked the preview tab, so the PDF was downloaded."), }); } catch (error) { pdfWindow?.close(); toast({ title: options?.errorTitle ?? "Could not open marshalling document", description: parseError(error, "Make sure the train has wagon allocations, then try again."), variant: "destructive", }); } }; const runDispatch = async () => { setDispatchConfirmOpen(false); try { await dispatch.mutateAsync(scheduleId); await openMarshallingDocument({ title: "Train dispatched", successDescription: "Marshalling document generated for the dispatched train.", errorTitle: "Train dispatched, but document could not open", }); } catch (err) { toast({ title: "Dispatch failed", description: parseError(err, "Could not dispatch"), variant: "destructive", }); } }; const handleAssign = async () => { if (!allSelectedIds.length) return; if (hasContainerStep) { const issues = validateLocalPlacements(containerUnits, containerPlacements); if (issues.length) { toast({ title: "Complete container assignments", description: issues.join(", "), variant: "destructive", }); return; } } try { const result = await assign.mutateAsync({ id: scheduleId, freightType, payload: { bookingIds: allSelectedIds, forceAssign, containerPlacements: hasContainerStep ? containerPlacements : undefined, }, }); toast({ title: "Bookings assigned — wagons auto-pinned" }); const refreshed = await detailQuery.refetch(); const saved = refreshed.data?.trainSet?.wagons ? placementsFromScheduleWagons(refreshed.data.trainSet.wagons) : []; if (saved.length) { setContainerPlacements(saved); } autoPreviewedRef.current = false; setActiveStep(finalizeStep); if (result.deferredBookings?.length) { toast({ title: "Partial assignment", description: `${result.deferredBookings.length} booking(s) deferred to next train`, }); } } catch (err) { toast({ title: "Assign failed", description: parseError(err, "Could not assign"), variant: "destructive", }); } }; const handleUnassign = async (bookingId: string) => { // Gov bookings never leave a train by removal — only by switching. The API // enforces this too; the guard here just gives the warning without a call. if (schedule?.bookings?.some((b) => b.id === bookingId && b.isGovernment)) { toast({ title: "Government booking cannot be removed", description: "Government bookings cannot be removed from the train. They can only be switched onto another allocation.", variant: "destructive", }); return; } try { await unassign.mutateAsync({ id: scheduleId, bookingId }); toast({ title: "Booking unassigned" }); setSelectedBookingIds((ids) => ids.filter((id) => id !== bookingId)); setPreviewResult(null); autoPreviewedRef.current = false; } catch (err) { toast({ title: "Unassign failed", description: parseError(err, "Could not unassign"), variant: "destructive", }); } }; const containerComplete = hasContainerStep && containerUnits.length > 0 && validateLocalPlacements(containerUnits, containerPlacements).length === 0; const finalizeComplete = ["SCHEDULED", "DISPATCHED", "ARRIVED"].includes( schedule.status, ); const stepsMeta = [ { key: "bookings", icon: Package, title: "Bookings", subtitle: "Select cargo & preview the plan", complete: Boolean(previewResult) || assignedIds.length > 0, }, { key: "wagon", icon: LayoutGrid, title: "Wagon plan", subtitle: "Review generated allocations", complete: displayWagonPlan.length > 0, }, ...(hasContainerStep ? [ { key: "container", icon: ContainerIcon, title: "Containers", subtitle: "Map units to wagon slots", complete: containerComplete, }, ] : []), { key: "finalize", icon: CheckCircle2, title: "Dispatch", subtitle: "Review the consist & dispatch", complete: finalizeComplete, }, ]; const completedCount = stepsMeta.filter((s) => s.complete).length; const progressPct = Math.round((completedCount / stepsMeta.length) * 100); const toggleStep = (i: number) => setActiveStep((cur) => (cur === i ? -1 : i)); const renderStepRightSlot = (key: string) => { if (key === "bookings") { if (previewResult) { return ( {previewResult.valid ? "Plan valid" : "Has issues"} ); } return allSelectedIds.length ? ( {allSelectedIds.length} selected ) : null; } if (key === "wagon" && displayWagonPlan.length) { return ( {schedule.reverseWagonOrder ? ( Reversed order ) : null} {displayWagonPlan.length} wagons ); } if (key === "container" && containerUnits.length) { return ( {containerUnits.length} units ); } if (key === "finalize") { return ; } return null; }; const renderStepBody = (key: string) => { if (key === "bookings") { return ( ({ id: b.id, reference: b.reference ?? b.id.slice(0, 8), weightTons: b.weightTons, isGovernment: b.isGovernment, wagonsRequired: b.wagonsRequired, contractReference: b.contractReference, origin: b.origin, destination: b.destination, }))} eligibleItems={eligibleQuery.data?.items ?? []} eligibleLoading={eligibleQuery.isLoading} selectedIds={allSelectedIds} onSelectionChange={(ids) => { const assigned = new Set(assignedIds); setSelectedBookingIds(ids.filter((id) => !assigned.has(id))); }} assignedIds={assignedIds} freightType={freightType} canRemove={canModifyBookings} onRemove={handleUnassign} onSwitch={canModifyBookings ? setSwitchTarget : undefined} /> {canEditBookings ? ( setForceAssign(e.currentTarget.checked)} size="sm" /> ) : null} {previewResult ? ( ) : null} ); } if (key === "wagon") { return ( {!displayWagonPlan.length && !previewResult ? ( Run a preview from the Bookings step to generate the wagon plan. ) : null} {isExportDisplay && displayWagonPlanOriented.length ? ( Shown rear-first (export direction) — positions keep their original numbers. ) : null} {canEditBookings && (previewResult || displayWagonPlan.length) ? ( {hasContainerStep ? ( ) : null} ) : null} ); } if (key === "container") { return ( {!containerUnits.length ? ( Run preview from the Bookings step to load container units for numbering. ) : ( )} {canEditBookings ? ( ) : null} ); } // finalize — the train is known here, so draw the full composition the // same way the batch board's composition tab does (interactive consist). return ( {schedule.trainSet ? ( ) : ( )} Ready to depart Dispatch begins rail movement and notifies the yard. {canDispatch ? ( ) : null} {!canDispatch ? ( No actions available for this schedule status. ) : null} ); }; return ( {schedule.reference ? ( {schedule.reference} ) : null} {schedule.route?.name ?? "Train schedule"} {schedule.trainNumber ? ( {schedule.trainNumber} ) : null} {schedule.train ? ( Train {schedule.train.code} ) : null} {(schedule.stops?.length ?? 0) >= 3 || (schedule.bookings ?? []).some( (b) => b.tradeDirection === "DOMESTIC", ) ? ( ) : ( )} {(schedule.trainSet?.wagons?.length ?? 0) > 0 ? ( ) : null} {canPrintMarshalling ? ( ) : null} {["DISPATCHED", "ARRIVED"].includes(schedule.status) ? ( ) : null} {["DISPATCHED", "ARRIVED"].includes(schedule.status) ? ( ) : null} {schedule.windowPhase === "PRE_WINDOW" ? ( ) : null} {["DRAFT", "SCHEDULED"].includes(schedule.status) ? ( ) : null} {gatepassApplies ? ( gatepassSecured ? ( ) : ( ) ) : null} {previewResult ? ( } > Preview {previewResult.valid ? "valid" : "has issues"} ) : null} 1 ? "Locomotives" : "Locomotive", value: locomotives.length ? locomotives.map((l) => l.code).join(" + ") : "—", hint: locomotives.length ? `${locomotives.length} locomotive${locomotives.length > 1 ? "s" : ""}` : "No locomotives assigned", icon: Train, }, ...(schedule.trainSet?.totalLengthMeters ? [ { label: "Train length", // Physical consist length — the same figure Train Builder shows. value: `${schedule.trainSet.totalLengthMeters}m`, hint: "built consist — matches Train Builder", icon: Ruler, }, ] : []), { label: "Bookings", value: schedule.bookings?.length ?? 0, hint: (() => { const intercity = (schedule.bookings ?? []).filter( (b) => b.tradeDirection === "DOMESTIC", ).length; return intercity > 0 ? `${intercity} intercity ride-along${intercity === 1 ? "" : "s"}` : undefined; })(), icon: Package, }, { label: "Wagons / load", // Gross: cargo load + wagon tare — the weight the locomotives // actually haul. Heaviest corridor edge when the server sends it; // plain consist sums over-report a multi-stop train (cross-leg // wagon sharing counts one physical wagon as several slots). value: (() => { const heaviest = schedule.trainSet?.heaviestLeg; const wagonCount = heaviest?.loadedWagonCount ?? schedule.trainSet?.wagonCount ?? displayWagonPlan.length; const grossTons = heaviest?.grossWeightTons ?? Math.round( ((schedule.trainSet?.totalWeightTons ?? 0) + (schedule.trainSet?.wagons ?? []).reduce( (sum, w) => sum + (Number(w.tareWeightTons) || 0), 0, )) * 100, ) / 100; return `${wagonCount} · ${grossTons}T`; })(), hint: schedule.trainSet?.heaviestLeg ? "heaviest leg · wagon tare + cargo" : "gross · wagon tare + cargo", icon: Weight, }, { label: "Departure", value: new Date(schedule.scheduledDepartureDate).toLocaleDateString("en", { month: "short", day: "2-digit", }), hint: new Date(schedule.scheduledDepartureDate).toLocaleTimeString("en", { hour: "2-digit", minute: "2-digit", }), icon: CalendarClock, }, ]} /> {/* Import loading confirmation — superseded by the per-booking Load/Unload toggle in the Workspace tab (works for all directions). Kept commented in case the import-only confirmation flow is needed again. {schedule?.direction === "IMPORT" ? ( Import loading confirmation Paid import bookings with a wagon allocated on this schedule. Marking loaded/unloaded is tracking only — it does not block dispatch. ) : null} */} }> Workflow }> Workspace }> Leg capacity }> History {/* Workflow header with ring progress */} Scheduling workflow {completedCount} of {stepsMeta.length} steps complete · expand any step to edit {progressPct}% } /> {stepsMeta.map((step, index) => ( toggleStep(index)} rightSlot={renderStepRightSlot(step.key)} > {renderStepBody(step.key)} ))} {/* */} { autoPreviewedRef.current = false; void detailQuery.refetch(); }} /> {scheduleId ? ( ) : null} {scheduleId ? : null} {scheduleId ? ( b.id)} opened={maintenanceOpen} onClose={() => setMaintenanceOpen(false)} onComplete={() => void detailQuery.refetch()} /> ) : null} setWindowSettingsOpen(false)} onSaved={() => void detailQuery.refetch()} /> setSwitchTarget(null)} govBooking={switchTarget} assignedBookings={schedule.bookings ?? []} loading={switchGov.isPending} onConfirm={async (removeBookingIds) => { if (!scheduleId || !switchTarget) return; try { await switchGov.mutateAsync({ id: scheduleId, governmentBookingId: switchTarget.id, removeBookingIds, }); toast({ title: `Government booking ${switchTarget.reference} switched onto the train` }); setSwitchTarget(null); setSelectedBookingIds([]); setPreviewResult(null); autoPreviewedRef.current = false; } catch (err) { toast({ title: "Switch failed", description: parseError(err, "Could not switch the government booking"), variant: "destructive", }); } }} /> setDispatchConfirmOpen(false)} centered radius="lg" title={ Dispatch this train? } > Dispatch locks the composition and begins rail movement. This cannot be undone. {hasDispatchWarnings ? ( } title="Some bookings are not fully ready" > {unassignedCount > 0 ? ( {unassignedCount} {" "} booking{unassignedCount === 1 ? "" : "s"} not assigned to a wagon ) : null} {unloadedCount > 0 ? ( {unloadedCount} {" "} wagon-assigned booking{unloadedCount === 1 ? "" : "s"} not loaded yet — mid-route boarders load from the track page when the train reaches their yard ) : null} {intercityNotLoadedCount > 0 ? ( {intercityNotLoadedCount} {" "} intercity ride-along{intercityNotLoadedCount === 1 ? "" : "s"} not loaded yet — load them from the Workspace tab (Yard work) before the train leaves their origin yard ) : null} You can still dispatch — confirm to proceed. ) : ( } > All bookings are assigned to a wagon and marked loaded. )} {visualization3DOpen ? ( setVisualization3DOpen(false)} /> ) : null} ); }