mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
745 lines
24 KiB
TypeScript
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>
|
|
);
|
|
}
|