mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 03:38:17 +00:00
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:
@@ -161,6 +161,8 @@ export const QUERY_KEYS = {
|
|||||||
["train-scheduling", "schedules", filters ?? {}] as const,
|
["train-scheduling", "schedules", filters ?? {}] as const,
|
||||||
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
|
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
|
||||||
track: (id: string) => ["train-scheduling", "track", 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) =>
|
batchBoard: (filters?: unknown) =>
|
||||||
["train-scheduling", "batch-board", "list", filters ?? {}] as const,
|
["train-scheduling", "batch-board", "list", filters ?? {}] as const,
|
||||||
batchBoardDetail: (scheduleId: string) =>
|
batchBoardDetail: (scheduleId: string) =>
|
||||||
|
|||||||
@@ -537,6 +537,10 @@ export const URL_CONSTANTS = {
|
|||||||
`/train-scheduling/schedules/${id}/export/load-list/document`,
|
`/train-scheduling/schedules/${id}/export/load-list/document`,
|
||||||
INTERCITY_MARSHALLING_DOCUMENT: (id: string) =>
|
INTERCITY_MARSHALLING_DOCUMENT: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/intercity/marshalling/document`,
|
`/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) =>
|
CHECKPOINTS: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/checkpoints`,
|
`/train-scheduling/schedules/${id}/checkpoints`,
|
||||||
CHECKPOINT: (id: string, sequenceNo: number) =>
|
CHECKPOINT: (id: string, sequenceNo: number) =>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
Route,
|
Route,
|
||||||
TrainFront,
|
TrainFront,
|
||||||
} from "lucide-react";
|
} 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 { CheckpointTimeModal } from "@/components/trainScheduling/CheckpointTimeModal";
|
||||||
import { CheckpointLogTable } from "@/components/trainScheduling/CheckpointLogTable";
|
import { CheckpointLogTable } from "@/components/trainScheduling/CheckpointLogTable";
|
||||||
@@ -119,18 +119,34 @@ export default function TrainScheduleTrackPage() {
|
|||||||
enabled: Boolean(scheduleId) && trackQuery.data?.status === "DISPATCHED",
|
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({
|
const intercityMarshalling = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: (stopIndex?: number) =>
|
||||||
trainSchedulingService.downloadIntercityMarshallingDocument(scheduleId ?? ""),
|
stopIndex != null
|
||||||
|
? trainSchedulingService.downloadMarshallingDocumentAt(scheduleId ?? "", stopIndex)
|
||||||
|
: trainSchedulingService.downloadIntercityMarshallingDocument(scheduleId ?? ""),
|
||||||
});
|
});
|
||||||
const openIntercityMarshalling = async () => {
|
const openIntercityMarshalling = async (stopIndex?: number) => {
|
||||||
const pdfWindow = window.open("", "_blank");
|
const pdfWindow = window.open("", "_blank");
|
||||||
try {
|
try {
|
||||||
const blob = await intercityMarshalling.mutateAsync();
|
const blob = await intercityMarshalling.mutateAsync(stopIndex);
|
||||||
const opened = openPdfBlob(blob, `intercity-marshalling-${scheduleId}.pdf`, pdfWindow);
|
const filename =
|
||||||
|
stopIndex != null ? `marshalling-${stopIndex}-${scheduleId}.pdf` : `intercity-marshalling-${scheduleId}.pdf`;
|
||||||
|
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||||
toast({
|
toast({
|
||||||
title: "Intercity marshalling ready",
|
title: stopIndex != null ? `Marshalling ${stopIndex} ready` : "Intercity marshalling ready",
|
||||||
description: opened
|
description: opened
|
||||||
? "The PDF opened in a browser tab for printing or saving."
|
? "The PDF opened in a browser tab for printing or saving."
|
||||||
: "The browser blocked the preview tab, so the PDF was downloaded.",
|
: "The browser blocked the preview tab, so the PDF was downloaded.",
|
||||||
@@ -313,7 +329,7 @@ export default function TrainScheduleTrackPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Box style={{ flex: 1 }} />
|
<Box style={{ flex: 1 }} />
|
||||||
{inTransit || arrived ? (
|
{(inTransit || arrived) && marshallingStops.length === 0 ? (
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
radius={9}
|
radius={9}
|
||||||
@@ -325,6 +341,31 @@ export default function TrainScheduleTrackPage() {
|
|||||||
Intercity Marshalling
|
Intercity Marshalling
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : 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>
|
</Group>
|
||||||
|
|
||||||
{/* ── Two-column work surface ── */}
|
{/* ── Two-column work surface ── */}
|
||||||
|
|||||||
@@ -257,13 +257,34 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
const switchGov = useMutation(api.trainScheduling.switchGovernmentBooking.mutationOptions());
|
const switchGov = useMutation(api.trainScheduling.switchGovernmentBooking.mutationOptions());
|
||||||
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
||||||
const downloadMarshalling = useMutation({
|
const downloadMarshalling = useMutation({
|
||||||
mutationFn: ({ id, direction, variant }: { id: string; direction?: string | null; variant?: "INTERCITY" }) =>
|
mutationFn: ({
|
||||||
variant === "INTERCITY"
|
id,
|
||||||
? trainSchedulingService.downloadIntercityMarshallingDocument(id)
|
direction,
|
||||||
: direction === "EXPORT"
|
variant,
|
||||||
? trainSchedulingService.downloadExportLoadListDocument(id)
|
stopIndex,
|
||||||
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
|
}: {
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const operation = gatepassQuery.data;
|
const operation = gatepassQuery.data;
|
||||||
@@ -506,6 +527,7 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
successDescription?: string;
|
successDescription?: string;
|
||||||
errorTitle?: string;
|
errorTitle?: string;
|
||||||
variant?: "INTERCITY";
|
variant?: "INTERCITY";
|
||||||
|
stopIndex?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const pdfWindow = window.open("", "_blank");
|
const pdfWindow = window.open("", "_blank");
|
||||||
try {
|
try {
|
||||||
@@ -513,13 +535,16 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
id: scheduleId,
|
id: scheduleId,
|
||||||
direction: schedule.direction,
|
direction: schedule.direction,
|
||||||
variant: options?.variant,
|
variant: options?.variant,
|
||||||
|
stopIndex: options?.stopIndex,
|
||||||
});
|
});
|
||||||
const prefix =
|
const prefix =
|
||||||
options?.variant === "INTERCITY"
|
options?.stopIndex != null
|
||||||
? "intercity-marshalling"
|
? `marshalling-${options.stopIndex}`
|
||||||
: schedule.direction === "EXPORT"
|
: options?.variant === "INTERCITY"
|
||||||
? "export-marshalling"
|
? "intercity-marshalling"
|
||||||
: "import-marshalling";
|
: schedule.direction === "EXPORT"
|
||||||
|
? "export-marshalling"
|
||||||
|
: "import-marshalling";
|
||||||
const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`;
|
const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`;
|
||||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||||
toast({
|
toast({
|
||||||
@@ -1108,7 +1133,7 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
Marshalling PDF
|
Marshalling PDF
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
) : null}
|
) : null}
|
||||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
{["DISPATCHED", "ARRIVED"].includes(schedule.status) && marshallingStops.length === 0 ? (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<FileText size={15} />}
|
leftSection={<FileText size={15} />}
|
||||||
disabled={downloadMarshalling.isPending}
|
disabled={downloadMarshalling.isPending}
|
||||||
@@ -1122,6 +1147,25 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
Intercity Marshalling
|
Intercity Marshalling
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
) : null}
|
) : 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) ? (
|
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
component={Link}
|
component={Link}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ import type {
|
|||||||
UpdateCheckpointPayload,
|
UpdateCheckpointPayload,
|
||||||
DispatchSchedulePayload,
|
DispatchSchedulePayload,
|
||||||
StaffBookingWindow,
|
StaffBookingWindow,
|
||||||
|
MarshallingStop,
|
||||||
ScheduleMergePreview,
|
ScheduleMergePreview,
|
||||||
TrainScheduleDetail,
|
TrainScheduleDetail,
|
||||||
TrainScheduleFilters,
|
TrainScheduleFilters,
|
||||||
@@ -595,6 +596,13 @@ export const api = {
|
|||||||
({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.track(id),
|
({ 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<
|
unassignedBookings: endpoint<
|
||||||
{ scheduleId: string },
|
{ scheduleId: string },
|
||||||
UnassignedBookingsResponse
|
UnassignedBookingsResponse
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import type {
|
|||||||
UpdateCheckpointPayload,
|
UpdateCheckpointPayload,
|
||||||
DispatchSchedulePayload,
|
DispatchSchedulePayload,
|
||||||
StaffBookingWindow,
|
StaffBookingWindow,
|
||||||
|
MarshallingStop,
|
||||||
ScheduleMergePreview,
|
ScheduleMergePreview,
|
||||||
TrainScheduleDetail,
|
TrainScheduleDetail,
|
||||||
UpdateScheduleWindowRulePayload,
|
UpdateScheduleWindowRulePayload,
|
||||||
@@ -760,6 +761,24 @@ export const trainSchedulingService = {
|
|||||||
return response.data;
|
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> => {
|
getTrack: async (scheduleId: string): Promise<TrainTrackResponse> => {
|
||||||
const response = await client.get<TrainTrackResponse>(
|
const response = await client.get<TrainTrackResponse>(
|
||||||
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId),
|
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId),
|
||||||
|
|||||||
@@ -933,6 +933,19 @@ export interface TrainCheckpoint {
|
|||||||
note: string | null;
|
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. */
|
/** The four station-work stamps, as a payload fragment both endpoints accept. */
|
||||||
export interface CheckpointHandlingTimes {
|
export interface CheckpointHandlingTimes {
|
||||||
unloadingStartedAt?: string | null;
|
unloadingStartedAt?: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user