import { Link, useParams } from "react-router-dom"; import { isAxiosError } from "axios"; import { useState } from "react"; import { ArrowLeft, CalendarClock, ChevronRight, FileText, Flag, ListChecks, MapPin, Package, PackageCheck, Pencil, Route, TrainFront, } from "lucide-react"; import { Box, Button, Group, Loader, Menu, Stack, Text } from "@mantine/core"; import { CheckpointTimeModal } from "@/components/trainScheduling/CheckpointTimeModal"; import { CheckpointLogTable } from "@/components/trainScheduling/CheckpointLogTable"; import { JourneySpine } from "@/components/trainScheduling/JourneySpine"; import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal"; import { TrackStatusCard } from "@/components/trainScheduling/TrackStatusCard"; import { Chip, SectionHead } from "@/components/trainScheduling/trackPrimitives"; import { track as T } from "@/components/trainScheduling/trackTheme"; import type { CheckpointHandlingTimes, TrackStation, TrainCheckpoint, } from "@/types/trainScheduling"; import { useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import { openPdfBlob } from "@/components/warehouses/pdf"; import { trainSchedulingService } from "@/services/trainScheduling.service"; type CheckpointModalValues = { occurredAt: string; note: string } & Required< Record >; /** * The station-work stamps off the modal. * * On an edit a cleared picker means "remove this stamp", so nulls are sent. * On a first log there is nothing to remove, and the record endpoint takes no * nulls — the untouched pickers are dropped instead. */ const pickHandling = ( values: CheckpointModalValues, keepNulls: boolean, ): CheckpointHandlingTimes => Object.fromEntries( ( [ "unloadingStartedAt", "unloadingCompletedAt", "loadingStartedAt", "loadingCompletedAt", ] as const ) .map((field) => [field, values[field]] as const) .filter(([, value]) => keepNulls || value !== null), ); 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; } return fallback; }; function formatDateTime(iso?: string | null) { if (!iso) return "—"; return new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); } const CARD = { background: T.surface, border: `1px solid ${T.border}`, borderRadius: 16, overflow: "hidden" as const, }; export default function TrainScheduleTrackPage() { const { scheduleId } = useParams<{ scheduleId: string }>(); const { toast } = useToast(); const trackQuery = useQuery( api.trainScheduling.trainTrack.queryOptions({ input: { id: scheduleId ?? "" }, enabled: Boolean(scheduleId), }), ); const recordCheckpoint = useMutation( api.trainScheduling.recordCheckpoint.mutationOptions(), ); const updateCheckpoint = useMutation( api.trainScheduling.updateCheckpoint.mutationOptions(), ); // Time-entry dialogs: logging a pass at a yard with no work (the yard-work // modal carries its own picker), and correcting an already-logged leg. const [logModal, setLogModal] = useState<{ station: TrackStation; isFinal: boolean; } | null>(null); const [editModal, setEditModal] = useState(null); // Yard work drives the log-pass modal: which bookings board/alight per stop. const yardWorkQuery = useQuery( api.trainScheduling.yardWork.queryOptions({ input: { scheduleId: scheduleId ?? "" }, enabled: Boolean(scheduleId) && trackQuery.data?.status === "DISPATCHED", }), ); // Marshalling: the on-board list, reprinted after station work. Numbered // per corridor stop that actually coupled/uncoupled something (Marshalling // 2, 3, 4…) — falls back to the single "current position" doc when nothing // has happened yet. const marshallingStopsQuery = useQuery( api.trainScheduling.marshallingStops.queryOptions({ input: { id: scheduleId ?? "" }, enabled: Boolean(scheduleId) && ["DISPATCHED", "ARRIVED"].includes(trackQuery.data?.status ?? ""), }), ); const marshallingStops = marshallingStopsQuery.data ?? []; const intercityMarshalling = useMutation({ mutationFn: (stopIndex?: number) => stopIndex != null ? trainSchedulingService.downloadMarshallingDocumentAt(scheduleId ?? "", stopIndex) : trainSchedulingService.downloadIntercityMarshallingDocument(scheduleId ?? ""), }); const openIntercityMarshalling = async (stopIndex?: number) => { const pdfWindow = window.open("", "_blank"); try { const blob = await intercityMarshalling.mutateAsync(stopIndex); const filename = stopIndex != null ? `marshalling-${stopIndex}-${scheduleId}.pdf` : `intercity-marshalling-${scheduleId}.pdf`; const opened = openPdfBlob(blob, filename, pdfWindow); toast({ title: stopIndex != null ? `Marshalling ${stopIndex} ready` : "Intercity marshalling ready", description: 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: "Could not open intercity marshalling document", description: parseError(error, "Please try again"), variant: "destructive", }); } }; const [yardModal, setYardModal] = useState<{ station: TrackStation; isFinal: boolean; alreadyLogged: boolean; } | null>(null); if (trackQuery.isLoading) { return ( ); } const track = trackQuery.data; if (!track || !scheduleId) { return ( Tracking data not found. ); } const canLog = track.status === "DISPATCHED"; const totalStations = track.stations.length; const reached = Math.min(track.currentSequenceNo + 1, totalStations); const progressPct = totalStations > 1 ? (track.currentSequenceNo / (totalStations - 1)) * 100 : 0; const clampedPct = Math.min(100, Math.max(0, progressPct)); const currentStation = track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—"; const inTransit = track.status === "DISPATCHED"; const arrived = track.status === "ARRIVED"; // Yard work at a station: boarders not yet loaded, and loaded bookings that // alight there. When either exists, logging the pass goes through the modal // so the operator sees (and can act on) both lists; empty yards log directly. const yardWorkFor = (station: TrackStation | undefined) => yardWorkQuery.data?.yards.find((y) => y.yardId === station?.yardId); const stationHasWork = (station: TrackStation | undefined) => { const yard = yardWorkFor(station); return Boolean( yard && (yard.toLoad.some((r) => !r.loadedAt) || yard.toUnload.some((r) => r.canUnload)), ); }; const handleLog = (sequenceNo: number) => { const station = track.stations.find((s) => s.sequenceNo === sequenceNo); if (!station) return; const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo; if (stationHasWork(station)) { setYardModal({ station, isFinal, alreadyLogged: false }); return; } setLogModal({ station, isFinal }); }; const submitLog = (values: CheckpointModalValues) => { if (!logModal) return; const { station, isFinal } = logModal; recordCheckpoint.mutate( { id: scheduleId, payload: { sequenceNo: station.sequenceNo, occurredAt: values.occurredAt, ...(values.note ? { note: values.note } : {}), // Nothing to clear on a first log — send only what was entered. ...pickHandling(values, false), }, }, { onSuccess: () => { setLogModal(null); toast({ title: isFinal ? "Train arrived — assets freed, moved to destination yard" : "Checkpoint logged", }); }, onError: (err) => toast({ title: "Could not log checkpoint", description: parseError(err, "Please try again"), variant: "destructive", }), }, ); }; const submitEdit = (values: CheckpointModalValues) => { if (!editModal) return; updateCheckpoint.mutate( { id: scheduleId, sequenceNo: editModal.sequenceNo, payload: { occurredAt: values.occurredAt, note: values.note || null, // Nulls are meaningful here: clearing a picker clears the stamp. ...pickHandling(values, true), }, }, { onSuccess: () => { setEditModal(null); toast({ title: "Checkpoint updated" }); }, onError: (err) => toast({ title: "Could not update checkpoint", description: parseError(err, "Please try again"), variant: "destructive", }), }, ); }; // Legs stay correctable for as long as the journey exists — while rolling // and after arrival. const canEdit = track.status === "DISPATCHED" || track.status === "ARRIVED"; // "Forgot to load" catch: while the train sits at the current station, any // boarder there that is still unloaded can be loaded until the next pass. const currentStationObj = track.stations.find( (s) => s.sequenceNo === track.currentSequenceNo, ); const currentYard = canLog ? yardWorkFor(currentStationObj) : undefined; const forgottenBoarders = currentYard?.toLoad.filter((r) => !r.loadedAt) ?? []; // The stop the operator acts on next — drives the left rail's action card. const nextStation = canLog ? track.stations.find((s) => s.sequenceNo === track.currentSequenceNo + 1) : undefined; const nextIsFinal = nextStation?.sequenceNo === track.stations[totalStations - 1]?.sequenceNo; return ( {/* ── Top bar ── */} Train scheduling {track.trainNumber ?? "Schedule"} · Tracking {(inTransit || arrived) && marshallingStops.length === 0 ? ( ) : null} {(inTransit || arrived) && marshallingStops.length > 0 ? ( {marshallingStops.map((stop) => ( void openIntercityMarshalling(stop.stopIndex)} > {`Marshalling ${stop.stopIndex} — ${stop.yardLabel}`} ))} ) : null} {/* ── Two-column work surface ── */} {/* left rail */} {/* next action */} {nextStation ? ( Next action {`STOP ${reached + 1} OF ${totalStations}`} {nextIsFinal ? `Mark arrived at ${nextStation.label}` : `Log pass at ${nextStation.label}`} {nextIsFinal ? "Marks the train arrived: remaining bookings arrive, assets are freed." : "Logging the pass marks arriving bookings and unlocks loading for cargo boarding here."} ) : null} {/* forgotten boarders */} {currentStationObj && forgottenBoarders.length > 0 ? ( {forgottenBoarders.length} booking {forgottenBoarders.length === 1 ? "" : "s"} not loaded The train is at {currentStationObj.label} — cargo boarding here can still be loaded before the next station is logged. ) : null} {/* main column */} } title="Journey & station work" hint={ canLog ? "Every stop with its pass time and loading windows — the final station marks arrival." : arrived ? "This train has arrived at its destination." : "Tracking becomes available once the train is dispatched." } right={ {[ [T.brand, "Passed"], [T.amber, "Active"], [T.text3, "Upcoming"], ].map(([color, label]) => ( {label} ))} } /> } title="Checkpoint log" hint="Raw event trail — every logged pass with its correction history" right={ {`${track.checkpoints.length} EVENT${ track.checkpoints.length === 1 ? "" : "S" }`} } /> setLogModal(null)} title={ logModal?.isFinal ? `Mark arrived at ${logModal.station.label}` : `Log pass at ${logModal?.station.label ?? "station"}` } icon={logModal?.isFinal ? : } description={ logModal?.isFinal ? "Marks the train arrived: remaining bookings arrive, assets are freed." : undefined } submitLabel={logModal?.isFinal ? "Mark arrived" : "Log pass"} submitColor={logModal?.isFinal ? "teal" : "edr-green"} loading={recordCheckpoint.isPending} onSubmit={submitLog} /> setEditModal(null)} title={`Edit ${editModal?.label ?? "checkpoint"}`} icon={} description="Corrects this leg's time, station work and note only — nothing else changes." initialOccurredAt={editModal?.occurredAt} initialNote={editModal?.note} initialHandling={editModal} submitLabel="Save" loading={updateCheckpoint.isPending} onSubmit={submitEdit} /> setYardModal(null)} scheduleId={scheduleId} station={yardModal?.station ?? null} stations={track.stations} isFinal={yardModal?.isFinal ?? false} alreadyLogged={yardModal?.alreadyLogged ?? false} /> ); }