Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx
Marshal dc38e843a6 feat: full wagon cancel, leg board, wagon dates
feat(freight): editable train leg times, SL invoice payer
2026-08-15 10:12:37 +00:00

745 lines
24 KiB
TypeScript

import { Link, useParams } from "react-router-dom";
import { isAxiosError } from "axios";
import { useState } from "react";
import {
ArrowLeft,
CalendarClock,
CheckCircle2,
FileText,
Flag,
MapPin,
Navigation,
PackageCheck,
Pencil,
Train,
} from "lucide-react";
import {
Alert,
Badge,
Box,
Button,
Group,
Loader,
Paper,
RingProgress,
Stack,
Text,
ThemeIcon,
Timeline,
Title,
} from "@mantine/core";
import { PageContainer } from "@/components/page";
import { CheckpointTimeModal } from "@/components/trainScheduling/CheckpointTimeModal";
import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal";
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
import type { TrackStation, TrainCheckpoint } from "@/types/trainScheduling";
import {
RouteCorridor,
StatusPill,
scheduleBrand,
} from "@/components/trainScheduling/scheduleVisuals";
import { freightBrand } from "@/theme/freight-brand";
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";
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",
});
}
/**
* A single fact in the hero's glass meta strip — icon chip + uppercase label +
* value, laid on the translucent panel over the gradient.
*/
function HeroStat({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<Box
style={{
width: 34,
height: 34,
borderRadius: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(255,255,255,0.16)",
border: "1px solid rgba(255,255,255,0.24)",
color: "white",
flexShrink: 0,
}}
>
{icon}
</Box>
<Stack gap={1} style={{ minWidth: 0 }}>
<Text
size="10px"
fw={700}
tt="uppercase"
style={{ letterSpacing: 0.6, color: "rgba(255,255,255,0.72)" }}
>
{label}
</Text>
<Text size="sm" fw={700} c="white" truncate>
{value}
</Text>
</Stack>
</Group>
);
}
/** Section header — icon chip + title + one-line hint. Shared by the cards. */
function SectionHead({
icon,
title,
hint,
}: {
icon: React.ReactNode;
title: string;
hint: string;
}) {
return (
<Group gap="sm" align="center" wrap="nowrap">
<ThemeIcon size={36} radius="md" variant="light" color="edr-green">
{icon}
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={800} size="sm">
{title}
</Text>
<Text size="xs" c="dimmed">
{hint}
</Text>
</Stack>
</Group>
);
}
const CARD_STYLE = {
borderColor: scheduleBrand.mutedBorder,
boxShadow: scheduleBrand.shadowSm,
} 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 2: the current on-board list, reprinted after station work.
const intercityMarshalling = useMutation({
mutationFn: () =>
trainSchedulingService.downloadIntercityMarshallingDocument(scheduleId ?? ""),
});
const openIntercityMarshalling = async () => {
const pdfWindow = window.open("", "_blank");
try {
const blob = await intercityMarshalling.mutateAsync();
const opened = openPdfBlob(blob, `intercity-marshalling-${scheduleId}.pdf`, pdfWindow);
toast({
title: "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 (
<PageContainer>
<Group justify="center" py="xl">
<Loader size="sm" color="edr-green" />
</Group>
</PageContainer>
);
}
const track = trackQuery.data;
if (!track || !scheduleId) {
return (
<PageContainer>
<Text c="dimmed" py="xl">
Tracking data not found.
</Text>
</PageContainer>
);
}
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: { occurredAt: string; note: string }) => {
if (!logModal) return;
const { station, isFinal } = logModal;
recordCheckpoint.mutate(
{
id: scheduleId,
payload: {
sequenceNo: station.sequenceNo,
occurredAt: values.occurredAt,
...(values.note ? { note: values.note } : {}),
},
},
{
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: { occurredAt: string; note: string }) => {
if (!editModal) return;
updateCheckpoint.mutate(
{
id: scheduleId,
sequenceNo: editModal.sequenceNo,
payload: { occurredAt: values.occurredAt, note: values.note || null },
},
{
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) ?? [];
return (
<PageContainer>
<Group justify="space-between" w="100%">
<Button
component={Link}
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<ArrowLeft size={16} />}
w="fit-content"
>
Back to schedule
</Button>
{inTransit || arrived ? (
<Button
variant="light"
color="edr-green"
radius="lg"
size="compact-sm"
leftSection={<FileText size={16} />}
loading={intercityMarshalling.isPending}
onClick={() => void openIntercityMarshalling()}
>
Intercity Marshalling
</Button>
) : null}
</Group>
{/* ── Hero: gradient wash, route + a bold progress ring woven together ── */}
<Paper
radius="lg"
p={0}
style={{ overflow: "hidden", boxShadow: scheduleBrand.shadow }}
>
<Box
style={{
background: scheduleBrand.heroGradient,
padding: "26px 28px",
position: "relative",
}}
>
{/* soft decorative glow, purely artistic */}
<Box
style={{
position: "absolute",
top: -80,
right: -60,
width: 260,
height: 260,
borderRadius: "50%",
background: "rgba(255,255,255,0.10)",
pointerEvents: "none",
}}
/>
<Group
justify="space-between"
align="flex-start"
wrap="wrap"
gap="xl"
style={{ position: "relative" }}
>
{/* left — identity + route */}
<Stack gap={14} style={{ minWidth: 260, flex: 1 }}>
<Group gap="md" align="center" wrap="nowrap">
<Box
style={{
width: 52,
height: 52,
borderRadius: 14,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(255,255,255,0.16)",
border: "1px solid rgba(255,255,255,0.26)",
color: "white",
flexShrink: 0,
}}
>
<Navigation size={26} />
</Box>
<Stack gap={6} style={{ minWidth: 0 }}>
<Group gap="sm" align="center" wrap="wrap">
<Title order={3} fw={800} c="white">
Train tracking
</Title>
{track.trainNumber ? (
<Badge
variant="white"
color="dark"
radius="sm"
styles={{ root: { color: freightBrand.primaryDark } }}
>
{track.trainNumber}
</Badge>
) : null}
{track.direction ? (
<Badge
variant="outline"
radius="sm"
styles={{
root: {
color: "white",
borderColor: "rgba(255,255,255,0.5)",
},
}}
>
{track.direction}
</Badge>
) : null}
</Group>
<Box maw={380}>
<RouteCorridor
origin={track.origin}
destination={track.destination}
variant="compact"
onDark
/>
</Box>
</Stack>
</Group>
<Group gap="sm">
<StatusPill status={track.status} size="md" />
<Box
px={12}
py={5}
style={{
borderRadius: 999,
background: "rgba(255,255,255,0.16)",
border: "1px solid rgba(255,255,255,0.24)",
}}
>
<Text size="xs" fw={700} c="white" style={{ letterSpacing: 0.2 }}>
{arrived
? "Journey complete"
: inTransit
? `En route · ${currentStation}`
: "Awaiting dispatch"}
</Text>
</Box>
</Group>
</Stack>
{/* right — progress ring, the artistic focal point */}
<RingProgress
size={132}
thickness={11}
roundCaps
sections={[{ value: clampedPct, color: "white" }]}
rootColor="rgba(255,255,255,0.22)"
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1} c="white">
{Math.round(clampedPct)}%
</Text>
<Text
size="10px"
fw={700}
tt="uppercase"
style={{ letterSpacing: 0.6, color: "rgba(255,255,255,0.8)" }}
>
{reached}/{totalStations} stops
</Text>
</Stack>
}
/>
</Group>
</Box>
{/* glass meta strip below the wash */}
<Group
justify="space-between"
wrap="wrap"
gap="lg"
px={28}
py="md"
style={{
background: freightBrand.primaryDark,
borderTop: "1px solid rgba(255,255,255,0.12)",
}}
>
<HeroStat icon={<MapPin size={16} />} label="Current" value={currentStation} />
<HeroStat
icon={<CalendarClock size={16} />}
label="Departed"
value={formatDateTime(track.actualDepartureAt)}
/>
<HeroStat
icon={<Flag size={16} />}
label="Arrived"
value={formatDateTime(track.actualArrivalAt)}
/>
<HeroStat
icon={<Train size={16} />}
label="Stations"
value={`${reached} of ${totalStations}`}
/>
</Group>
</Paper>
{/* ── Route corridor ── */}
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
<Stack gap="md">
<SectionHead
icon={<Navigation size={17} />}
title="Route corridor"
hint={
canLog
? "Log the train passing each station; the final station marks arrival."
: arrived
? "This train has arrived at its destination."
: "Tracking becomes available once the train is dispatched."
}
/>
<RouteCorridorTrack
stations={track.stations}
currentSequenceNo={track.currentSequenceNo}
checkpoints={track.checkpoints}
canLog={canLog}
loggingSeq={
recordCheckpoint.isPending
? recordCheckpoint.variables?.payload.sequenceNo
: null
}
onLogCheckpoint={handleLog}
onEditCheckpoint={canEdit ? setEditModal : undefined}
/>
{/* Cargo the operator forgot: boarders at the CURRENT station stay
loadable until the next pass is logged. */}
{currentStationObj && forgottenBoarders.length > 0 ? (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<PackageCheck size={16} />}
title={`${forgottenBoarders.length} booking${
forgottenBoarders.length === 1 ? "" : "s"
} at ${currentStationObj.label} not loaded yet`}
>
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
<Text size="sm">
The train is at {currentStationObj.label} cargo boarding here can
still be loaded before the next station is logged.
</Text>
<Button
size="compact-sm"
variant="light"
color="yellow"
onClick={() =>
setYardModal({
station: currentStationObj,
isFinal:
currentStationObj.sequenceNo ===
track.stations[totalStations - 1]?.sequenceNo,
alreadyLogged: true,
})
}
>
Open yard work
</Button>
</Group>
</Alert>
) : null}
</Stack>
</Paper>
{/* ── Checkpoint log ── */}
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
<Group justify="space-between" wrap="nowrap" mb="md">
<SectionHead
icon={<CheckCircle2 size={17} />}
title="Checkpoint log"
hint={`${track.checkpoints.length} event${
track.checkpoints.length === 1 ? "" : "s"
} recorded`}
/>
</Group>
{track.checkpoints.length === 0 ? (
<Stack
align="center"
gap="xs"
py={40}
style={{
borderRadius: 14,
border: `1px dashed ${scheduleBrand.mutedBorder}`,
background: scheduleBrand.softSurface,
}}
>
<ThemeIcon size={48} radius="xl" variant="light" color="edr-green">
<MapPin size={22} />
</ThemeIcon>
<Text size="sm" fw={700} c="gray.7">
No checkpoints yet
</Text>
<Text size="xs" c="dimmed" ta="center" maw={320}>
Each station the train passes will be logged here with its
timestamp.
</Text>
</Stack>
) : (
<Timeline
active={track.checkpoints.length}
bulletSize={24}
lineWidth={2}
color="edr-green"
>
{track.checkpoints.map((cp) => (
<Timeline.Item
key={cp.id}
bullet={
cp.kind === "ARRIVED" ? (
<CheckCircle2 size={13} />
) : (
<MapPin size={12} />
)
}
title={
<Group gap="sm" justify="space-between" wrap="nowrap">
<Group gap="sm">
<Text fw={700} size="sm">
{cp.label ?? `Station ${cp.sequenceNo}`}
</Text>
<Badge
size="xs"
radius="sm"
variant="light"
color={
cp.kind === "ARRIVED"
? "teal"
: cp.kind === "DEPARTED"
? "blue"
: "edr-green"
}
>
{cp.kind}
</Badge>
</Group>
{canEdit ? (
<Button
size="compact-xs"
radius="md"
variant="light"
color="gray"
leftSection={<Pencil size={12} />}
onClick={() => setEditModal(cp)}
>
Edit
</Button>
) : null}
</Group>
}
>
<Text size="xs" c="dimmed">
{formatDateTime(cp.occurredAt)}
</Text>
{cp.note ? (
<Text size="xs" mt={2}>
{cp.note}
</Text>
) : null}
</Timeline.Item>
))}
</Timeline>
)}
</Paper>
<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 and note only — nothing else changes."
initialOccurredAt={editModal?.occurredAt}
initialNote={editModal?.note}
submitLabel="Save"
loading={updateCheckpoint.isPending}
onSubmit={submitEdit}
/>
<LogPassYardWorkModal
opened={yardModal !== null}
onClose={() => setYardModal(null)}
scheduleId={scheduleId}
station={yardModal?.station ?? null}
isFinal={yardModal?.isFinal ?? false}
alreadyLogged={yardModal?.alreadyLogged ?? false}
/>
</PageContainer>
);
}