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

@@ -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) =>

View File

@@ -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) =>

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}

View File

@@ -85,6 +85,7 @@ import type {
UpdateCheckpointPayload,
DispatchSchedulePayload,
StaffBookingWindow,
MarshallingStop,
ScheduleMergePreview,
TrainScheduleDetail,
TrainScheduleFilters,
@@ -595,6 +596,13 @@ export const api = {
({ id }) => 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

View File

@@ -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<MarshallingStop[]> => {
const response = await client.get<MarshallingStop[]>(
URL_CONSTANTS.TRAIN_SCHEDULING.MARSHALLING_STOPS(scheduleId),
);
return unwrap(response.data);
},
downloadMarshallingDocumentAt: async (
scheduleId: string,
stopIndex: number,
): Promise<Blob> => {
const response = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.MARSHALLING_DOCUMENT_AT(scheduleId, stopIndex),
{ responseType: "blob" },
);
return response.data;
},
getTrack: async (scheduleId: string): Promise<TrainTrackResponse> => {
const response = await client.get<TrainTrackResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId),

View File

@@ -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;