Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-28 11:08:35 +00:00
93 changed files with 5301 additions and 2035 deletions

View File

@@ -90,17 +90,8 @@ export default function TrainBuilderDetailPage() {
const [yardModalOpen, setYardModalOpen] = useState(false);
const [disbandOpen, setDisbandOpen] = useState(false);
const [deactivateOpen, setDeactivateOpen] = useState(false);
const [maintenanceTarget, setMaintenanceTarget] =
useState<TrainCompositionWagon | null>(null);
const [maintenanceNote, setMaintenanceNote] = useState("");
// Clearing the note with the target stops one wagon's reason being carried
// over onto the next wagon sent to maintenance.
const closeMaintenance = () => {
setMaintenanceTarget(null);
setMaintenanceNote("");
};
// Detach-approval flow: on a SCHEDULED run, detach/maintenance is filed as a
// request (with reason) and executed by a second staffer's approval.
// Every detach / maintenance move asks for a reason first — it is recorded
// as an auto-approved audit row and on the train's wagon history.
const [requestTarget, setRequestTarget] = useState<{
wagon: TrainCompositionWagon;
action: "DETACH" | "MAINTENANCE";
@@ -110,15 +101,8 @@ export default function TrainBuilderDetailPage() {
setRequestTarget(null);
setRequestReason("");
};
const [rejectTarget, setRejectTarget] = useState<WagonDetachRequestRow | null>(null);
const [rejectNote, setRejectNote] = useState("");
const closeReject = () => {
setRejectTarget(null);
setRejectNote("");
};
const { user } = useAuth();
const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons);
const canApproveDetach = hasPermission(user, FREIGHT_PERMS.trains.approveWagonDetach);
const canChangeLocomotives = hasPermission(user, FREIGHT_PERMS.trains.changeLocomotives);
const canChangeYard = hasPermission(user, FREIGHT_PERMS.trains.changeYard);
const canChangeWagonYard = hasPermission(user, FREIGHT_PERMS.trains.changeWagonYard);
@@ -149,35 +133,17 @@ export default function TrainBuilderDetailPage() {
enabled: Boolean(id),
}),
);
const createDetachRequest = useMutation(
api.trainBuilder.createDetachRequest.mutationOptions(),
);
const approveDetachRequest = useMutation(
api.trainBuilder.approveDetachRequest.mutationOptions(),
);
const rejectDetachRequest = useMutation(
api.trainBuilder.rejectDetachRequest.mutationOptions(),
);
const disband = useMutation(api.trainBuilder.disband.mutationOptions());
const deactivate = useMutation(api.trainBuilder.deactivate.mutationOptions());
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
const composition = compositionQuery.data;
// Approval kicks in once a run is SCHEDULED. DRAFT stays direct-edit; a
// dispatched train is frozen outright (composition.editable is false).
const requiresDetachApproval = (composition?.activeSchedules ?? []).some(
(s) => s.status === "SCHEDULED",
);
const detachRequests = useMemo(
() => detachRequestsQuery.data ?? [],
[detachRequestsQuery.data],
);
const pendingDetachRequests = detachRequests.filter((r) => r.status === "PENDING");
const pendingWagonIds = useMemo(
() => new Set(detachRequests.filter((r) => r.status === "PENDING").map((r) => r.wagonId)),
[detachRequests],
);
// The diagram memoizes off its `locomotives`/`wagons` props; building those
// arrays inline in JSX would hand it a new identity on every render and
@@ -270,32 +236,19 @@ export default function TrainBuilderDetailPage() {
[withToast, reorderWagons.mutateAsync, trainId],
);
const wagons = composition?.wagons;
const openDetachRequest = useCallback(
const openDetachReason = useCallback(
(wagonId: string, action: "DETACH" | "MAINTENANCE") => {
if (pendingWagonIds.has(wagonId)) {
toast({
title: "A detach request for this wagon is already pending approval",
});
return;
}
const wagon = wagons?.find((w) => w.id === wagonId);
if (wagon) setRequestTarget({ wagon, action });
},
[pendingWagonIds, wagons, toast],
[wagons],
);
const handleRemove = useCallback(
(wagonId: string) => {
if (!trainId) return;
if (requiresDetachApproval) {
openDetachRequest(wagonId, "DETACH");
return;
}
void withToast(
() => removeWagon.mutateAsync({ id: trainId, wagonId }),
"Could not detach wagon",
);
openDetachReason(wagonId, "DETACH");
},
[withToast, removeWagon.mutateAsync, trainId, requiresDetachApproval, openDetachRequest],
[trainId, openDetachReason],
);
const handleChangeWagonYard = useCallback(
(wagonId: string, currentYardId: string) => {
@@ -319,13 +272,9 @@ export default function TrainBuilderDetailPage() {
);
const handleMaintenance = useCallback(
(wagon: TrainCompositionWagon) => {
if (requiresDetachApproval) {
openDetachRequest(wagon.id, "MAINTENANCE");
return;
}
setMaintenanceTarget(wagon);
openDetachReason(wagon.id, "MAINTENANCE");
},
[requiresDetachApproval, openDetachRequest],
[openDetachReason],
);
if (compositionQuery.isLoading) {
@@ -502,9 +451,11 @@ export default function TrainBuilderDetailPage() {
</Alert>
) : null}
{!composition.editable ? (
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
This train is out on a dispatched run its composition is frozen until arrival.
{(composition.activeSchedules ?? []).some((s) => s.status === "DISPATCHED") ? (
<Alert color="blue" icon={<AlertTriangle size={16} />}>
This train is out on a dispatched run. You can still edit its composition
the dispatched run keeps the wagon plan it departed with, and your changes
apply to scheduled (not yet departed) runs only.
</Alert>
) : null}
@@ -563,7 +514,7 @@ export default function TrainBuilderDetailPage() {
))}
</Group>
{detachRequests.length ? (
{/* {detachRequests.length ? (
<Card>
<Stack gap="sm">
<Group justify="space-between">
@@ -576,8 +527,8 @@ export default function TrainBuilderDetailPage() {
</Group>
<Text size="xs" c="dimmed">
While this train is on a scheduled run, detaching a wagon (or sending it to
maintenance) needs a second staff member's approval. Decided requests stay
here as the audit trail.
maintenance) requires a reason — recorded here as the audit trail of who
did it and why.
</Text>
{detachRequests.map((req) => {
const isOwn = Boolean(req.requestedById && user?.id === req.requestedById);
@@ -622,49 +573,9 @@ export default function TrainBuilderDetailPage() {
</Text>
) : null}
</Stack>
{req.status === "PENDING" && canApproveDetach ? (
<Group gap="xs" wrap="nowrap">
<Tooltip
label="You filed this request — a different staff member must approve it"
disabled={!isOwn}
withArrow
>
<Button
size="compact-sm"
color="green"
disabled={isOwn}
loading={approveDetachRequest.isPending}
onClick={() =>
void withToast(async () => {
await approveDetachRequest.mutateAsync({
id: composition.id,
requestId: req.id,
});
toast({
title: `Wagon ${req.wagonNumber} ${
req.action === "MAINTENANCE"
? "sent to maintenance"
: "detached"
}`,
});
}, "Could not approve request")
}
>
Approve
</Button>
</Tooltip>
<Button
size="compact-sm"
variant="light"
color="red"
onClick={() => setRejectTarget(req)}
>
Reject
</Button>
</Group>
) : req.status === "PENDING" ? (
{req.status === "PENDING" ? (
<Text size="xs" c="dimmed">
Awaiting approval
Legacy request — approval flow removed
</Text>
) : null}
</Group>
@@ -672,7 +583,7 @@ export default function TrainBuilderDetailPage() {
})}
</Stack>
</Card>
) : null}
) : null} */}
<Stack gap="sm">
<TrainCompositionDiagram
@@ -811,71 +722,14 @@ export default function TrainBuilderDetailPage() {
onClose={() => setYardModalOpen(false)}
/>
<Modal
opened={Boolean(maintenanceTarget)}
onClose={closeMaintenance}
title={<Text fw={600}>Send wagon to maintenance?</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Wagon{" "}
<Text span fw={700} ff="monospace" c="dark">
{maintenanceTarget?.wagonNumber}
</Text>{" "}
is detached from train{" "}
<Text span fw={700} c="dark">
{trainRunLabel}
</Text>{" "}
and set to MAINTENANCE it stays out of the available pool until it
clears. The detach is stamped with the time and this train's run
numbers in the wagon's history.
</Text>
<Textarea
label="Note"
placeholder="Optional note (e.g. reason for maintenance)"
value={maintenanceNote}
onChange={(e) => setMaintenanceNote(e.currentTarget.value)}
autosize
minRows={2}
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeMaintenance}>
Keep in consist
</Button>
<Button
color="orange"
leftSection={<Wrench size={16} />}
loading={maintenanceWagon.isPending}
onClick={() =>
void withToast(async () => {
await maintenanceWagon.mutateAsync({
id: composition.id,
wagonId: maintenanceTarget!.id,
note: maintenanceNote.trim() || undefined,
});
toast({
title: `Wagon ${maintenanceTarget!.wagonNumber} sent to maintenance`,
});
closeMaintenance();
}, "Could not send wagon to maintenance")
}
>
Send to maintenance
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={Boolean(requestTarget)}
onClose={closeRequest}
title={
<Text fw={600}>
{requestTarget?.action === "MAINTENANCE"
? "Request maintenance approval?"
: "Request detach approval?"}
? "Send wagon to maintenance?"
: "Detach wagon?"}
</Text>
}
radius="lg"
@@ -883,22 +737,28 @@ export default function TrainBuilderDetailPage() {
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Train{" "}
<Text span fw={700} c="dark">
{trainRunLabel}
</Text>{" "}
is on a scheduled run, so wagon{" "}
Wagon{" "}
<Text span fw={700} ff="monospace" c="dark">
{requestTarget?.wagon.wagonNumber}
</Text>{" "}
is not detached now your request goes to a staff member with approval
rights, and the{" "}
{requestTarget?.action === "MAINTENANCE" ? "maintenance move" : "detach"}{" "}
happens the moment they approve it.
{requestTarget?.action === "MAINTENANCE"
? "leaves train "
: "is detached from train "}
<Text span fw={700} c="dark">
{trainRunLabel}
</Text>{" "}
{requestTarget?.action === "MAINTENANCE"
? "and is set to MAINTENANCE — it stays out of the available pool until it clears."
: "immediately."}{" "}
The reason is required and shows in this train&apos;s History tab.
</Text>
<Textarea
label="Reason"
placeholder="Why must this wagon leave the scheduled consist? (required)"
placeholder={
requestTarget?.action === "MAINTENANCE"
? "Why is this wagon going to maintenance? (required)"
: "Why is this wagon leaving the consist? (required)"
}
value={requestReason}
onChange={(e) => setRequestReason(e.currentTarget.value)}
autosize
@@ -919,73 +779,36 @@ export default function TrainBuilderDetailPage() {
)
}
disabled={!requestReason.trim()}
loading={createDetachRequest.isPending}
loading={removeWagon.isPending || maintenanceWagon.isPending}
onClick={() =>
void withToast(async () => {
await createDetachRequest.mutateAsync({
id: composition.id,
wagonId: requestTarget!.wagon.id,
action: requestTarget!.action,
reason: requestReason.trim(),
});
if (requestTarget!.action === "MAINTENANCE") {
await maintenanceWagon.mutateAsync({
id: composition.id,
wagonId: requestTarget!.wagon.id,
note: requestReason.trim(),
});
} else {
await removeWagon.mutateAsync({
id: composition.id,
wagonId: requestTarget!.wagon.id,
reason: requestReason.trim(),
});
}
toast({
title: `Request for wagon ${requestTarget!.wagon.wagonNumber} filed — awaiting approval`,
title: `Wagon ${requestTarget!.wagon.wagonNumber} ${
requestTarget!.action === "MAINTENANCE"
? "sent to maintenance"
: "detached"
}`,
});
closeRequest();
}, "Could not file the request")
}, "Could not detach the wagon")
}
>
Request approval
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={Boolean(rejectTarget)}
onClose={closeReject}
title={<Text fw={600}>Reject this request?</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Wagon{" "}
<Text span fw={700} ff="monospace" c="dark">
{rejectTarget?.wagonNumber}
</Text>{" "}
stays in the consist. The requester sees your note in the request history.
</Text>
<Textarea
label="Why is it rejected?"
placeholder="Required"
value={rejectNote}
onChange={(e) => setRejectNote(e.currentTarget.value)}
autosize
minRows={2}
required
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeReject}>
Cancel
</Button>
<Button
color="red"
disabled={!rejectNote.trim()}
loading={rejectDetachRequest.isPending}
onClick={() =>
void withToast(async () => {
await rejectDetachRequest.mutateAsync({
id: composition.id,
requestId: rejectTarget!.id,
note: rejectNote.trim(),
});
toast({ title: `Request for wagon ${rejectTarget!.wagonNumber} rejected` });
closeReject();
}, "Could not reject the request")
}
>
Reject request
{requestTarget?.action === "MAINTENANCE"
? "Send to maintenance"
: "Detach wagon"}
</Button>
</Group>
</Stack>

View File

@@ -4,48 +4,31 @@ import { useState } from "react";
import {
ArrowLeft,
CalendarClock,
CheckCircle2,
Clock,
ChevronRight,
FileText,
Flag,
ListChecks,
MapPin,
Navigation,
Package,
PackageCheck,
Pencil,
Train,
Route,
TrainFront,
} from "lucide-react";
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
import {
Alert,
Badge,
Box,
Button,
Group,
Loader,
Paper,
RingProgress,
Stack,
Text,
ThemeIcon,
Timeline,
Title,
} from "@mantine/core";
import { Box, Button, Group, Loader, Stack, Text } from "@mantine/core";
import { PageContainer } from "@/components/page";
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 { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
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 {
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";
@@ -80,23 +63,6 @@ const pickHandling = (
.filter(([, value]) => keepNulls || value !== null),
);
/**
* Total loading and unloading at a stop, the way the reports measure it:
* earliest start to latest finish, so a stop that only loaded or only unloaded
* still reads. Null when nothing was logged.
*/
const handlingHours = (cp: TrainCheckpoint): number | null => {
const times = [cp.unloadingStartedAt, cp.loadingStartedAt]
.filter((v): v is string => Boolean(v))
.map((v) => new Date(v).getTime());
const ends = [cp.loadingCompletedAt, cp.unloadingCompletedAt]
.filter((v): v is string => Boolean(v))
.map((v) => new Date(v).getTime());
if (!times.length || !ends.length) return null;
const hours = (Math.max(...ends) - Math.min(...times)) / 3_600_000;
return Math.round(hours * 10) / 10;
};
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const data = error.response?.data as Record<string, unknown> | undefined;
@@ -117,85 +83,12 @@ function formatDateTime(iso?: string | null) {
});
}
/**
* 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;
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 }>();
@@ -259,22 +152,18 @@ export default function TrainScheduleTrackPage() {
if (trackQuery.isLoading) {
return (
<PageContainer>
<Group justify="center" py="xl">
<Loader size="sm" color="edr-green" />
</Group>
</PageContainer>
<Group justify="center" py="xl">
<Loader size="sm" color="edr-green" />
</Group>
);
}
const track = trackQuery.data;
if (!track || !scheduleId) {
return (
<PageContainer>
<Text c="dimmed" py="xl">
Tracking data not found.
</Text>
</PageContainer>
<Text c="dimmed" py="xl" px="lg">
Tracking data not found.
</Text>
);
}
@@ -386,27 +275,50 @@ export default function TrainScheduleTrackPage() {
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 (
<PageContainer>
<Group justify="space-between" w="100%">
<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="subtle"
color="gray"
variant="default"
radius={9}
size="compact-sm"
leftSection={<ArrowLeft size={16} />}
w="fit-content"
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 ? (
<Button
variant="light"
color="edr-green"
radius="lg"
variant="default"
radius={9}
size="compact-sm"
leftSection={<FileText size={16} />}
leftSection={<FileText size={15} color={T.brand} />}
loading={intercityMarshalling.isPending}
onClick={() => void openIntercityMarshalling()}
>
@@ -415,423 +327,239 @@ export default function TrainScheduleTrackPage() {
) : null}
</Group>
{/* ── Hero: gradient wash, route + a bold progress ring woven together ── */}
<Paper
radius="lg"
p={0}
style={{ overflow: "hidden", boxShadow: scheduleBrand.shadow }}
{/* ── Two-column work surface ── */}
<Group
align="flex-start"
gap={28}
px={36}
pt={28}
pb={56}
wrap="wrap"
style={{ width: "100%" }}
>
<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."
{/* 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"
}
/>
<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}
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}`,
},
]}
/>
{/* 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.
{/* 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"
variant="light"
color="yellow"
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: currentStationObj,
isFinal:
currentStationObj.sequenceNo ===
track.stations[totalStations - 1]?.sequenceNo,
alreadyLogged: true,
station: nextStation,
isFinal: Boolean(nextIsFinal),
alreadyLogged: false,
})
}
>
Open yard work
Yard work
</Button>
</Group>
</Alert>
</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>
</Paper>
{/* ── Loading / unloading windows per station ── */}
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
<SectionHead
icon={<Clock size={17} />}
title="Loading & unloading windows"
hint="Start and end each station's work window — times, duration and who recorded them"
/>
<Stack gap="sm" mt="md">
{track.stations.map((s, i) => {
const isFirst = i === 0;
const isLast = i === track.stations.length - 1;
const workLog = track.stationWorkLogs?.[s.yardId];
return (
<Paper
key={s.yardId}
withBorder
radius="md"
p="sm"
style={{
background:
track.currentSequenceNo === s.sequenceNo
? "var(--mantine-color-green-0)"
: undefined,
}}
>
<Group gap={10} mb={6} wrap="nowrap">
<ThemeIcon size={30} radius="xl" variant="light" color="edr-green">
{isLast ? <Flag size={15} /> : <MapPin size={15} />}
</ThemeIcon>
<Text fw={700} size="sm">
{s.label}
</Text>
{isFirst ? (
<Badge size="xs" variant="light" color="edr-green">
origin
</Badge>
) : null}
{isLast ? (
<Badge size="xs" variant="light" color="gray">
destination
</Badge>
) : null}
{track.currentSequenceNo === s.sequenceNo ? (
<Badge size="xs" variant="filled" color="edr-green">
train here
</Badge>
) : null}
</Group>
<Stack gap={6} pl={40}>
{!isLast ? (
<StationWorkControls
scheduleId={scheduleId ?? ""}
yardId={s.yardId}
phase="loading"
log={workLog?.loading}
/>
) : null}
{!isFirst ? (
<StationWorkControls
scheduleId={scheduleId ?? ""}
yardId={s.yardId}
phase="unloading"
log={workLog?.unloading}
/>
) : null}
</Stack>
</Paper>
);
})}
</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}`}
{/* 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>
<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>
{handlingHours(cp) !== null ? (
<Text size="xs" c="dimmed" mt={2}>
Loading + unloading {handlingHours(cp)} h
</Text>
) : null}
{cp.note ? (
<Text size="xs" mt={2}>
{cp.note}
</Text>
) : null}
</Timeline.Item>
))}
</Timeline>
)}
</Paper>
))}
</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}
@@ -875,6 +603,6 @@ export default function TrainScheduleTrackPage() {
isFinal={yardModal?.isFinal ?? false}
alreadyLogged={yardModal?.alreadyLogged ?? false}
/>
</PageContainer>
</Box>
);
}

View File

@@ -135,14 +135,8 @@ export default function TrainScheduleV2DetailPage() {
// Actual departure — staff often dispatch on paper first and record it later,
// so the time is picked (defaults to now when the dialog opens).
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
// Loading is manual: dispatch decides the fate of every unloaded origin
// boarder — checked = loaded and departs, unchecked = left behind (wagon
// freed, booking back to the pool). Default unchecked; government bookings
// cannot be removed from a train so they are forced on.
const [dispatchLoadedIds, setDispatchLoadedIds] = useState<Set<string>>(new Set());
const openDispatchConfirm = () => {
setDispatchAt(new Date());
setDispatchLoadedIds(new Set());
setDispatchConfirmOpen(true);
};
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
@@ -473,9 +467,8 @@ export default function TrainScheduleV2DetailPage() {
// per yard from the track page's log-pass flow. Everything below is advisory.
const hasDispatchWarnings =
unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0;
// Unloaded boarders at the TRAIN's origin — the dispatch dialog's manual
// load/leave list. Mirrors the API's unloadedOriginBoarderIds predicate
// (plus government, which is shown but forced-loaded).
// Unloaded boarders at the TRAIN's origin — all sent as loaded on dispatch.
// Mirrors the API's unloadedOriginBoarderIds predicate (plus government).
const originYardId = schedule.originStation?.id;
const pendingOriginBoarders = dispatchBookings.filter(
(b) =>
@@ -489,26 +482,18 @@ export default function TrainScheduleV2DetailPage() {
// Shipping-line bookings ride from accept on the credit ledger.
(Boolean(b.shippingLineCompanyId) && b.status === "FULLY_EXECUTED")),
);
const dispatchLeftCount = pendingOriginBoarders.filter(
(b) => !b.isGovernment && !dispatchLoadedIds.has(b.id),
).length;
// Origin loading time window: dispatch (which marks the ticked boarders
// loaded) is server-rejected until "Start loading" was clicked for the
// origin yard, so the button mirrors that gate.
// Origin loading time window: dispatch (which marks the boarders loaded)
// is server-rejected until "Start loading" was clicked for the origin
// yard, so the button mirrors that gate.
const originLoadingLog = originYardId
? schedule.stationWorkLogs?.[originYardId]?.loading
: undefined;
const originLoadingStarted = Boolean(originLoadingLog?.startedAt);
const originLoadingEnded = Boolean(originLoadingLog?.endedAt);
const dispatchBoardersKept = pendingOriginBoarders.some(
(b) => b.isGovernment || dispatchLoadedIds.has(b.id),
);
const dispatchNeedsLoadingStart = dispatchBoardersKept && !originLoadingStarted;
// A train never departs mid-loading: once the window opened (or cargo is to
// board), it must be ENDED before dispatch — same gate the server enforces.
const dispatchNeedsLoadingEnd =
(dispatchBoardersKept || originLoadingStarted) && !originLoadingEnded;
const dispatchBlockedByLoading = dispatchNeedsLoadingStart || dispatchNeedsLoadingEnd;
// Dispatch requires the origin's loading window to be COMPLETE (started AND
// ended): not started → disabled, in progress → disabled, ended → active.
// Same gate the server enforces.
const dispatchBlockedByLoading = !originLoadingEnded;
const finalizeStep = hasContainerStep ? 3 : 2;
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
@@ -562,9 +547,9 @@ export default function TrainScheduleV2DetailPage() {
id: scheduleId,
payload: {
...(dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {}),
loadedBookingIds: pendingOriginBoarders
.filter((b) => b.isGovernment || dispatchLoadedIds.has(b.id))
.map((b) => b.id),
// No per-booking ticking in the dispatch dialog: every pending origin
// boarder rides — none are left behind at dispatch time.
loadedBookingIds: pendingOriginBoarders.map((b) => b.id),
},
});
await openMarshallingDocument({
@@ -967,15 +952,11 @@ export default function TrainScheduleV2DetailPage() {
phase="loading"
log={originLoadingLog}
/>
{dispatchNeedsLoadingStart ? (
{dispatchBlockedByLoading ? (
<Text size="xs" c="dimmed">
Start loading before dispatching the ticked bookings are marked
loaded at dispatch, which needs an open loading window.
</Text>
) : dispatchNeedsLoadingEnd ? (
<Text size="xs" c="dimmed">
End the loading window before dispatching a train never departs
mid-loading.
{originLoadingStarted
? "End the loading window before dispatching — a train never departs mid-loading."
: "Start and end the loading window before dispatching — dispatch needs a completed loading window."}
</Text>
) : null}
</Stack>
@@ -1599,56 +1580,6 @@ export default function TrainScheduleV2DetailPage() {
radius="md"
/>
{pendingOriginBoarders.length > 0 ? (
<Stack gap={6}>
<Text size="sm" fw={700}>
Cargo boarding at {schedule.originStation?.label ?? "the origin yard"}
tick what was loaded
</Text>
{originYardId ? (
<StationWorkControls
scheduleId={scheduleId}
yardId={originYardId}
phase="loading"
log={originLoadingLog}
/>
) : null}
<Text size="xs" c="dimmed">
Unticked bookings are left behind: removed from this train, their
wagons freed, and the booking returned to the pool for a later
schedule. The customer is notified.
</Text>
<Stack gap={6} mah={220} style={{ overflowY: "auto" }}>
{pendingOriginBoarders.map((b) => (
<Checkbox
key={b.id}
size="sm"
checked={b.isGovernment || dispatchLoadedIds.has(b.id)}
disabled={b.isGovernment}
onChange={(e) => {
const next = new Set(dispatchLoadedIds);
if (e.currentTarget.checked) next.add(b.id);
else next.delete(b.id);
setDispatchLoadedIds(next);
}}
label={
<Text size="sm" span>
{b.reference ?? b.id.slice(0, 8)} {b.customer ?? "Unknown customer"}
{b.isGovernment ? " (government — always rides)" : ""}
</Text>
}
/>
))}
</Stack>
{dispatchLeftCount > 0 ? (
<Text size="xs" c="orange.7" fw={600}>
{dispatchLeftCount} booking{dispatchLeftCount === 1 ? "" : "s"} will
be left behind and returned to the booking pool.
</Text>
) : null}
</Stack>
) : null}
{hasDispatchWarnings ? (
<Alert
color="orange"
@@ -1711,9 +1642,9 @@ export default function TrainScheduleV2DetailPage() {
</Button>
<Tooltip
label={
dispatchNeedsLoadingStart
? "Start loading at the origin station first — dispatch marks the ticked bookings loaded"
: "End the loading window at the origin station first — a train never departs mid-loading"
originLoadingStarted
? "End the loading window at the origin station first — a train never departs mid-loading"
: "Start and end the loading window at the origin station first — dispatch needs a completed loading window"
}
disabled={!dispatchBlockedByLoading}
>