diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 54e4e51d6..fb5ab66d5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -453,6 +453,19 @@ export class TrainSchedulingController { return this.trainSchedulingService.confirmImportLoadedOnTrain(id, dto); } + @Post("schedules/:id/confirm-loading") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch)", + }) + confirmScheduleLoading( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ImportDjiboutiActionDto, + ) { + return this.trainSchedulingService.confirmScheduleLoading(id, dto); + } + @Post("schedules/:id/import-djibouti/depart") @TrainSchedulingManage() @ApiOperation({ summary: "Depart loaded import train from Djibouti" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index be1b09070..a28df8520 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1515,6 +1515,24 @@ export class TrainSchedulingService { return this.getImportDjiboutiOperation(schedule.id); } + /** + * Confirm cargo is loaded on the train from the workspace, for any direction. + * For import-from-Djibouti trains this stamps the ImportDjiboutiOperation's + * loadedOnTrainAt (the flag dispatch checks) — gatepass must already be granted. + * For every other schedule there is no departure loading gate, so this is a + * success no-op and simply returns the current detail. + */ + async confirmScheduleLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (this.isImportDjiboutiSchedule(schedule)) { + await this.confirmImportLoadedOnTrain(scheduleId, dto); + } + return this.getTrainScheduleById(scheduleId); + } + async departImportFromDjibouti(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { const schedule = await this.getImportDjiboutiSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); @@ -3931,6 +3949,18 @@ export class TrainSchedulingService { const allocationIds = allocations.map((a) => a.id); const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId)); + // Import-from-Djibouti trains can only dispatch once loading is confirmed + // (loadedOnTrainAt on the operation). Other directions have no departure + // loading gate, so the workspace shows the confirm button as already done. + const requiresLoadingConfirmation = this.isImportDjiboutiSchedule(schedule); + let loadingConfirmed = !requiresLoadingConfirmation; + if (requiresLoadingConfirmation) { + const op = await this.dataSource + .getRepository(ImportDjiboutiOperation) + .findOne({ where: { trainScheduleId: schedule.id } }); + loadingConfirmed = Boolean(op?.loadedOnTrainAt); + } + const windowCfg = await this.getWindowConfig(); const [containerItems, bulkLoads] = await Promise.all([ @@ -3964,6 +3994,8 @@ export class TrainSchedulingService { freightType: this.resolveScheduleFreightType(schedule), trainNumber: schedule.trainNumber ?? null, direction: schedule.direction ?? null, + requiresLoadingConfirmation, + loadingConfirmed, // Booking-window phase + phase deadlines drive the countdown timers in the // operations workspace (display only — the window engine enforces them). windowPhase: schedule.windowPhase ?? null, diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index c93e4863c..6a6ba09d9 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -24,7 +24,7 @@ import { Inbox, PackageCheck, PackageX, - Repeat, + // Repeat, // used by the hidden Move (reassign) button Train, Weight, X, @@ -156,6 +156,9 @@ export function ScheduleWorkspacePanel({ const setLoading = useMutation( api.trainScheduling.setLoadingStatus.mutationOptions(), ); + const confirmLoading = useMutation( + api.trainScheduling.confirmLoading.mutationOptions(), + ); const moveSchedule = useMutation( api.trainScheduling.moveBookingSchedule.mutationOptions(), ); @@ -266,6 +269,25 @@ export function ScheduleWorkspacePanel({ ); }; + const doConfirmLoading = () => { + confirmLoading + .mutateAsync({ id: schedule.id }) + .then(() => { + toast({ title: "Loading confirmed", description: "The train is cleared to dispatch." }); + onChanged(); + }) + .catch((error) => + toast({ + title: "Could not confirm loading", + description: apiErrorMessage( + error, + "Grant the Djibouti gatepass first, then confirm loading.", + ), + variant: "destructive", + }), + ); + }; + const doMove = () => { if (!moveBookingId || !moveTarget) return; moveSchedule @@ -376,6 +398,54 @@ export function ScheduleWorkspacePanel({ ) : null} + {/* Loading confirmation — required before dispatch for import-Djibouti + trains; shown for every direction so staff have one place to confirm. */} + {canManage ? ( + + + {schedule.loadingConfirmed ? ( + + ) : ( + + )} + + {schedule.loadingConfirmed + ? "Loading confirmed — cleared to dispatch" + : "Confirm loading before dispatching this train"} + + + {!schedule.loadingConfirmed ? ( + + ) : null} + + ) : null} + {/* Two-panel board */} {/* Pool */} @@ -477,6 +547,7 @@ export function ScheduleWorkspacePanel({ ) : null} + {/* Reassign-to-another-train — hidden for now. @@ -1002,6 +1028,9 @@ export default function TrainScheduleV2DetailPage() { ]} /> + {/* Import loading confirmation — superseded by the per-booking Load/Unload + toggle in the Workspace tab (works for all directions). Kept commented + in case the import-only confirmation flow is needed again. {schedule?.direction === "IMPORT" ? ( @@ -1018,6 +1047,7 @@ export default function TrainScheduleV2DetailPage() { ) : null} + */} @@ -1119,6 +1149,102 @@ export default function TrainScheduleV2DetailPage() { onClose={() => setWindowSettingsOpen(false)} onSaved={() => void detailQuery.refetch()} /> + + setDispatchConfirmOpen(false)} + centered + radius="lg" + title={ + + + Dispatch this train? + + } + > + + + Dispatch locks the composition and begins rail movement. This cannot be + undone. + + + {loadingBlocksDispatch ? ( + } + title="Loading not confirmed" + > + This import train cannot depart until loading is confirmed. Use{" "} + + Confirm loading + {" "} + in the Workspace tab first. + + ) : null} + + {hasDispatchWarnings ? ( + } + title="Some bookings are not fully ready" + > + + {unassignedCount > 0 ? ( + + + {unassignedCount} + {" "} + booking{unassignedCount === 1 ? "" : "s"} not assigned to a wagon + + ) : null} + {unloadedCount > 0 ? ( + + + {unloadedCount} + {" "} + wagon-assigned booking{unloadedCount === 1 ? "" : "s"} still marked + unloaded + + ) : null} + + + You can still dispatch — confirm to proceed. + + + ) : ( + } + > + All bookings are assigned to a wagon and marked loaded. + + )} + + + + + + + ); } diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 50b79b23e..919c97d63 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -558,6 +558,14 @@ export const api = { () => TRAIN_SCHEDULING_INVALIDATIONS, ), + confirmLoading: endpoint<{ id: string }, TrainScheduleDetail>( + "train-scheduling", + "confirm-loading", + ({ id }) => trainSchedulingService.confirmLoading(id), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + pinWagons: endpoint< { id: string; payload: PinWagonsPayload }, TrainScheduleDetail 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 020d03b53..8c969b06d 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -369,6 +369,16 @@ export const trainSchedulingService = { return unwrap(response.data); }, + confirmLoading: async ( + scheduleId: string, + ): Promise => { + const response = await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.CONFIRM_LOADING(scheduleId), + {}, + ); + return unwrap(response.data); + }, + getImportDjiboutiOperation: async ( scheduleId: string, ): Promise => { diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 37cbff685..4807d00fb 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -429,6 +429,10 @@ export interface TrainScheduleDetail { freightType?: FreightType | null; trainNumber?: string | null; direction?: string | null; + /** True when this schedule needs loading confirmed before dispatch (import-Djibouti). */ + requiresLoadingConfirmation?: boolean; + /** True when loading is already confirmed (or not required for this direction). */ + loadingConfirmed?: boolean; windowPhase?: BookingWindowPhase | string | null; windowOpensAt?: string | null; windowClosesAt?: string | null;