feat(train-scheduling): numbered marshalling-doc picker per corridor stop

Backend now generates a separate marshalling document per corridor stop
where the consist actually coupled/uncoupled/switched something
(marshallingStops/marshallingDocumentAt), instead of one 'current
position' doc. Wires that into the backoffice:

- TrainScheduleV2DetailPage: the single Intercity Marshalling menu item
  becomes one item per stop with a logged change, falling back to the
  old single item when nothing has happened yet.
- TrainScheduleTrackPage: same fallback/menu treatment on its own
  marshalling button.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-08-28 11:49:08 +00:00
parent 8be886eb1d
commit 9e1e680394
7 changed files with 152 additions and 21 deletions

View File

@@ -15,7 +15,7 @@ import {
Route,
TrainFront,
} from "lucide-react";
import { Box, Button, Group, Loader, Stack, Text } from "@mantine/core";
import { Box, Button, Group, Loader, Menu, Stack, Text } from "@mantine/core";
import { CheckpointTimeModal } from "@/components/trainScheduling/CheckpointTimeModal";
import { CheckpointLogTable } from "@/components/trainScheduling/CheckpointLogTable";
@@ -119,18 +119,34 @@ export default function TrainScheduleTrackPage() {
enabled: Boolean(scheduleId) && trackQuery.data?.status === "DISPATCHED",
}),
);
// Marshalling 2: the current on-board list, reprinted after station work.
// 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: () =>
trainSchedulingService.downloadIntercityMarshallingDocument(scheduleId ?? ""),
mutationFn: (stopIndex?: number) =>
stopIndex != null
? trainSchedulingService.downloadMarshallingDocumentAt(scheduleId ?? "", stopIndex)
: trainSchedulingService.downloadIntercityMarshallingDocument(scheduleId ?? ""),
});
const openIntercityMarshalling = async () => {
const openIntercityMarshalling = async (stopIndex?: number) => {
const pdfWindow = window.open("", "_blank");
try {
const blob = await intercityMarshalling.mutateAsync();
const opened = openPdfBlob(blob, `intercity-marshalling-${scheduleId}.pdf`, pdfWindow);
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: "Intercity marshalling ready",
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.",
@@ -313,7 +329,7 @@ export default function TrainScheduleTrackPage() {
</Text>
</Group>
<Box style={{ flex: 1 }} />
{inTransit || arrived ? (
{(inTransit || arrived) && marshallingStops.length === 0 ? (
<Button
variant="default"
radius={9}
@@ -325,6 +341,31 @@ export default function TrainScheduleTrackPage() {
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 ── */}

View File

@@ -257,13 +257,34 @@ export default function TrainScheduleV2DetailPage() {
const switchGov = useMutation(api.trainScheduling.switchGovernmentBooking.mutationOptions());
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
const downloadMarshalling = useMutation({
mutationFn: ({ id, direction, variant }: { id: string; direction?: string | null; variant?: "INTERCITY" }) =>
variant === "INTERCITY"
? trainSchedulingService.downloadIntercityMarshallingDocument(id)
: direction === "EXPORT"
? trainSchedulingService.downloadExportLoadListDocument(id)
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
mutationFn: ({
id,
direction,
variant,
stopIndex,
}: {
id: string;
direction?: string | null;
variant?: "INTERCITY";
stopIndex?: number;
}) =>
stopIndex != null
? trainSchedulingService.downloadMarshallingDocumentAt(id, stopIndex)
: variant === "INTERCITY"
? trainSchedulingService.downloadIntercityMarshallingDocument(id)
: direction === "EXPORT"
? trainSchedulingService.downloadExportLoadListDocument(id)
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
});
// Numbered marshalling docs (Marshalling 2, 3, 4…) — one per corridor stop
// that actually coupled/uncoupled something. Empty when nothing has yet.
const marshallingStopsQuery = useQuery(
api.trainScheduling.marshallingStops.queryOptions({
input: { id: scheduleId ?? "" },
enabled: Boolean(scheduleId) && ["DISPATCHED", "ARRIVED"].includes(schedule?.status ?? ""),
}),
);
const marshallingStops = marshallingStopsQuery.data ?? [];
useEffect(() => {
const operation = gatepassQuery.data;
@@ -506,6 +527,7 @@ export default function TrainScheduleV2DetailPage() {
successDescription?: string;
errorTitle?: string;
variant?: "INTERCITY";
stopIndex?: number;
}) => {
const pdfWindow = window.open("", "_blank");
try {
@@ -513,13 +535,16 @@ export default function TrainScheduleV2DetailPage() {
id: scheduleId,
direction: schedule.direction,
variant: options?.variant,
stopIndex: options?.stopIndex,
});
const prefix =
options?.variant === "INTERCITY"
? "intercity-marshalling"
: schedule.direction === "EXPORT"
? "export-marshalling"
: "import-marshalling";
options?.stopIndex != null
? `marshalling-${options.stopIndex}`
: options?.variant === "INTERCITY"
? "intercity-marshalling"
: schedule.direction === "EXPORT"
? "export-marshalling"
: "import-marshalling";
const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`;
const opened = openPdfBlob(blob, filename, pdfWindow);
toast({
@@ -1108,7 +1133,7 @@ export default function TrainScheduleV2DetailPage() {
Marshalling PDF
</Menu.Item>
) : null}
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
{["DISPATCHED", "ARRIVED"].includes(schedule.status) && marshallingStops.length === 0 ? (
<Menu.Item
leftSection={<FileText size={15} />}
disabled={downloadMarshalling.isPending}
@@ -1122,6 +1147,25 @@ export default function TrainScheduleV2DetailPage() {
Intercity Marshalling
</Menu.Item>
) : null}
{/* One item per corridor stop that actually coupled/uncoupled
something (Marshalling 2, 3, 4…) — replaces the single
"current position" item once anything has happened. */}
{marshallingStops.map((stop) => (
<Menu.Item
key={stop.stopIndex}
leftSection={<FileText size={15} />}
disabled={downloadMarshalling.isPending}
onClick={() =>
void openMarshallingDocument({
title: `Marshalling ${stop.stopIndex} ready`,
successDescription: `${stop.yardLabel} — coupled/uncoupled wagons included.`,
stopIndex: stop.stopIndex,
})
}
>
{`Marshalling ${stop.stopIndex}${stop.yardLabel}`}
</Menu.Item>
))}
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
<Menu.Item
component={Link}