Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx
marshal ec5df4b328 Merge pull request #1472 from Tria-plc/freight_feature/usermanagement
feat: Implement handling for partially loaded bookings in train sched…
2026-09-01 23:56:07 +03:00

651 lines
22 KiB
TypeScript

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<keyof CheckpointHandlingTimes, string | null>
>;
/**
* 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<string, unknown> | 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<TrainCheckpoint | null>(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 (
<Group justify="center" py="xl">
<Loader size="sm" color="edr-green" />
</Group>
);
}
const track = trackQuery.data;
if (!track || !scheduleId) {
return (
<Text c="dimmed" py="xl" px="lg">
Tracking data not found.
</Text>
);
}
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 (
<Box style={{ background: T.bg, minHeight: "100%" }}>
{/* ── Top bar ── */}
<Group
gap={14}
px={36}
py={16}
wrap="nowrap"
align="center"
style={{ background: T.surface, borderBottom: `1px solid ${T.border}` }}
>
<Button
component={Link}
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
variant="default"
radius={9}
size="compact-sm"
leftSection={<ArrowLeft size={15} />}
>
Back to schedule
</Button>
<Group gap={8} align="center" wrap="nowrap" visibleFrom="sm">
<Text size="12.5px" c={T.muted}>
Train scheduling
</Text>
<ChevronRight size={13} color={T.text3} />
<Text size="12.5px" fw={600} c={T.text}>
{track.trainNumber ?? "Schedule"} · Tracking
</Text>
</Group>
<Box style={{ flex: 1 }} />
{(inTransit || arrived) && marshallingStops.length === 0 ? (
<Button
variant="default"
radius={9}
size="compact-sm"
leftSection={<FileText size={15} color={T.brand} />}
loading={intercityMarshalling.isPending}
onClick={() => void openIntercityMarshalling()}
>
Intercity Marshalling
</Button>
) : null}
{(inTransit || arrived) && marshallingStops.length > 0 ? (
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<Button
variant="default"
radius={9}
size="compact-sm"
leftSection={<FileText size={15} color={T.brand} />}
loading={intercityMarshalling.isPending}
>
Marshalling
</Button>
</Menu.Target>
<Menu.Dropdown>
{marshallingStops.map((stop) => (
<Menu.Item
key={stop.stopIndex}
onClick={() => void openIntercityMarshalling(stop.stopIndex)}
>
{`Marshalling ${stop.stopIndex}${stop.yardLabel}`}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
) : null}
</Group>
{/* ── Two-column work surface ── */}
<Group
align="flex-start"
gap={28}
px={36}
pt={28}
pb={56}
wrap="wrap"
style={{ width: "100%" }}
>
{/* left rail */}
<Stack gap={16} style={{ width: 352, flexShrink: 0, flexGrow: 1, maxWidth: "100%" }}>
<TrackStatusCard
trainNumber={track.trainNumber}
direction={track.direction}
status={track.status}
progressPct={clampedPct}
reached={reached}
totalStations={totalStations}
currentStation={currentStation}
stateLine={
arrived
? "Journey complete"
: inTransit
? "En route"
: "Awaiting dispatch"
}
origin={track.origin}
destination={track.destination}
stats={[
{
icon: CalendarClock,
label: "Departed",
value: formatDateTime(track.actualDepartureAt),
},
{
icon: Flag,
label: "Arrived",
value: formatDateTime(track.actualArrivalAt),
},
{ icon: MapPin, label: "Current station", value: currentStation },
{
icon: TrainFront,
label: "Stations reached",
value: `${reached} of ${totalStations}`,
},
]}
/>
{/* next action */}
{nextStation ? (
<Stack gap={14} p={18} style={CARD}>
<Group gap={9} align="center" wrap="nowrap">
<Text
size="9.5px"
fw={700}
tt="uppercase"
c={T.muted}
style={{ letterSpacing: 1 }}
>
Next action
</Text>
<Box style={{ flex: 1 }} />
<Chip bg={T.surface3} fg={T.text2}>
{`STOP ${reached + 1} OF ${totalStations}`}
</Chip>
</Group>
<Text size="15px" fw={700} c={T.text} lh={1.3}>
{nextIsFinal
? `Mark arrived at ${nextStation.label}`
: `Log pass at ${nextStation.label}`}
</Text>
<Text size="12px" c={T.text2} lh={1.45}>
{nextIsFinal
? "Marks the train arrived: remaining bookings arrive, assets are freed."
: "Logging the pass marks arriving bookings and unlocks loading for cargo boarding here."}
</Text>
<Group gap={8} wrap="nowrap">
<Button
color="edr-green"
radius={9}
size="compact-sm"
style={{ flex: 1 }}
leftSection={nextIsFinal ? <Flag size={14} /> : <MapPin size={14} />}
loading={
recordCheckpoint.isPending &&
recordCheckpoint.variables?.payload.sequenceNo ===
nextStation.sequenceNo
}
onClick={() => handleLog(nextStation.sequenceNo)}
>
{nextIsFinal ? "Mark arrived" : "Log pass"}
</Button>
<Button
variant="default"
radius={9}
size="compact-sm"
leftSection={<Package size={14} />}
onClick={() =>
setYardModal({
station: nextStation,
isFinal: Boolean(nextIsFinal),
alreadyLogged: false,
})
}
>
Yard work
</Button>
</Group>
</Stack>
) : null}
{/* forgotten boarders */}
{currentStationObj && forgottenBoarders.length > 0 ? (
<Stack
gap={11}
p={16}
style={{
background: T.amberDim,
border: `1px solid ${T.amberBorder}`,
borderRadius: 14,
}}
>
<Group gap={9} align="center" wrap="nowrap">
<PackageCheck size={16} color={T.amber} style={{ flexShrink: 0 }} />
<Text size="13px" fw={700} c={T.amber}>
{forgottenBoarders.length} booking
{forgottenBoarders.length === 1 ? "" : "s"} not loaded
</Text>
</Group>
<Text size="11.5px" c={T.amberText} lh={1.45}>
The train is at {currentStationObj.label} cargo boarding here can still
be loaded before the next station is logged.
</Text>
<Button
size="compact-sm"
radius={9}
variant="white"
w="fit-content"
styles={{
root: { borderColor: T.amberBorder, border: `1px solid ${T.amberBorder}` },
label: { color: T.amber, fontWeight: 700, fontSize: 12.5 },
}}
onClick={() =>
setYardModal({
station: currentStationObj,
isFinal:
currentStationObj.sequenceNo ===
track.stations[totalStations - 1]?.sequenceNo,
alreadyLogged: true,
})
}
>
Open yard work
</Button>
</Stack>
) : null}
</Stack>
{/* main column */}
<Stack gap={20} style={{ flex: 1, minWidth: 520 }}>
<Box style={CARD}>
<SectionHead
icon={<Route size={17} />}
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={
<Group gap={12} wrap="nowrap" visibleFrom="md">
{[
[T.brand, "Passed"],
[T.amber, "Active"],
[T.text3, "Upcoming"],
].map(([color, label]) => (
<Group key={label} gap={5} wrap="nowrap">
<Box
style={{
width: 7,
height: 7,
borderRadius: 999,
background: color,
}}
/>
<Text size="11px" fw={600} c={T.muted}>
{label}
</Text>
</Group>
))}
</Group>
}
/>
<JourneySpine
scheduleId={scheduleId}
stations={track.stations}
currentSequenceNo={track.currentSequenceNo}
checkpoints={track.checkpoints}
stationWorkLogs={track.stationWorkLogs}
canLog={canLog}
loggingSeq={
recordCheckpoint.isPending
? recordCheckpoint.variables?.payload.sequenceNo
: null
}
onLogCheckpoint={handleLog}
onEditCheckpoint={canEdit ? setEditModal : undefined}
/>
</Box>
<Box style={CARD}>
<SectionHead
icon={<ListChecks size={16} />}
title="Checkpoint log"
hint="Raw event trail — every logged pass with its correction history"
right={
<Chip bg={T.surface3} fg={T.text2}>
{`${track.checkpoints.length} EVENT${
track.checkpoints.length === 1 ? "" : "S"
}`}
</Chip>
}
/>
<CheckpointLogTable
checkpoints={track.checkpoints}
onEdit={canEdit ? setEditModal : undefined}
/>
</Box>
</Stack>
</Group>
<CheckpointTimeModal
opened={logModal !== null}
onClose={() => setLogModal(null)}
title={
logModal?.isFinal
? `Mark arrived at ${logModal.station.label}`
: `Log pass at ${logModal?.station.label ?? "station"}`
}
icon={logModal?.isFinal ? <Flag size={18} /> : <MapPin size={18} />}
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}
/>
<CheckpointTimeModal
opened={editModal !== null}
onClose={() => setEditModal(null)}
title={`Edit ${editModal?.label ?? "checkpoint"}`}
icon={<Pencil size={18} />}
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}
/>
<LogPassYardWorkModal
opened={yardModal !== null}
onClose={() => setYardModal(null)}
scheduleId={scheduleId}
station={yardModal?.station ?? null}
stations={track.stations}
isFinal={yardModal?.isFinal ?? false}
alreadyLogged={yardModal?.alreadyLogged ?? false}
/>
</Box>
);
}