diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
index 66a55b555..0ee03a626 100644
--- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
@@ -161,6 +161,8 @@ export const QUERY_KEYS = {
["train-scheduling", "schedules", filters ?? {}] as const,
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
track: (id: string) => ["train-scheduling", "track", id] as const,
+ marshallingStops: (id: string) =>
+ ["train-scheduling", "marshalling-stops", id] as const,
batchBoard: (filters?: unknown) =>
["train-scheduling", "batch-board", "list", filters ?? {}] as const,
batchBoardDetail: (scheduleId: string) =>
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index 16635162a..8603444d2 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -537,6 +537,10 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/export/load-list/document`,
INTERCITY_MARSHALLING_DOCUMENT: (id: string) =>
`/train-scheduling/schedules/${id}/intercity/marshalling/document`,
+ MARSHALLING_STOPS: (id: string) =>
+ `/train-scheduling/schedules/${id}/marshalling/stops`,
+ MARSHALLING_DOCUMENT_AT: (id: string, stopIndex: number) =>
+ `/train-scheduling/schedules/${id}/marshalling/document/${stopIndex}`,
CHECKPOINTS: (id: string) =>
`/train-scheduling/schedules/${id}/checkpoints`,
CHECKPOINT: (id: string, sequenceNo: number) =>
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx
index 3d74c0fb5..2b1961362 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx
@@ -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() {
- {inTransit || arrived ? (
+ {(inTransit || arrived) && marshallingStops.length === 0 ? (
+
+
+ {marshallingStops.map((stop) => (
+ void openIntercityMarshalling(stop.stopIndex)}
+ >
+ {`Marshalling ${stop.stopIndex} — ${stop.yardLabel}`}
+
+ ))}
+
+
+ ) : null}
{/* ── Two-column work surface ── */}
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
index 17fdbb6e4..26d9d7f88 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
@@ -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
) : null}
- {["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
+ {["DISPATCHED", "ARRIVED"].includes(schedule.status) && marshallingStops.length === 0 ? (
}
disabled={downloadMarshalling.isPending}
@@ -1122,6 +1147,25 @@ export default function TrainScheduleV2DetailPage() {
Intercity Marshalling
) : 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) => (
+ }
+ 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}`}
+
+ ))}
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
QUERY_KEYS.TRAIN_SCHEDULING.track(id),
),
+ marshallingStops: endpoint<{ id: string }, MarshallingStop[]>(
+ "train-scheduling",
+ "marshalling-stops",
+ ({ id }) => trainSchedulingService.getMarshallingStops(id),
+ ({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.marshallingStops(id),
+ ),
+
unassignedBookings: endpoint<
{ scheduleId: string },
UnassignedBookingsResponse
diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts
index 5ca505cf3..f983baf15 100644
--- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts
@@ -33,6 +33,7 @@ import type {
UpdateCheckpointPayload,
DispatchSchedulePayload,
StaffBookingWindow,
+ MarshallingStop,
ScheduleMergePreview,
TrainScheduleDetail,
UpdateScheduleWindowRulePayload,
@@ -760,6 +761,24 @@ export const trainSchedulingService = {
return response.data;
},
+ getMarshallingStops: async (scheduleId: string): Promise => {
+ const response = await client.get(
+ URL_CONSTANTS.TRAIN_SCHEDULING.MARSHALLING_STOPS(scheduleId),
+ );
+ return unwrap(response.data);
+ },
+
+ downloadMarshallingDocumentAt: async (
+ scheduleId: string,
+ stopIndex: number,
+ ): Promise => {
+ const response = await client.get(
+ URL_CONSTANTS.TRAIN_SCHEDULING.MARSHALLING_DOCUMENT_AT(scheduleId, stopIndex),
+ { responseType: "blob" },
+ );
+ return response.data;
+ },
+
getTrack: async (scheduleId: string): Promise => {
const response = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId),
diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
index 3af6cc959..3536cddf9 100644
--- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
+++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
@@ -933,6 +933,19 @@ export interface TrainCheckpoint {
note: string | null;
}
+/**
+ * One corridor stop with a logged consist change (coupled/uncoupled/switched)
+ * — its own numbered marshalling document exists. `stopIndex` starts at 2;
+ * origin is always Marshalling 1 (the plain import/export load list, not
+ * this list). A stop with only a routine checkpoint never appears here.
+ */
+export interface MarshallingStop {
+ stopIndex: number;
+ yardId: string;
+ yardLabel: string;
+ firstOccurredAt: string;
+}
+
/** The four station-work stamps, as a payload fragment both endpoints accept. */
export interface CheckpointHandlingTimes {
unloadingStartedAt?: string | null;