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 ? (
+ }
+ loading={confirmLoading.isPending}
+ onClick={doConfirmLoading}
+ >
+ Confirm loading
+
+ ) : 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.
+
+ )}
+
+
+
+ }
+ loading={dispatch.isPending}
+ disabled={loadingBlocksDispatch}
+ onClick={() => void runDispatch()}
+ >
+ {hasDispatchWarnings ? "Dispatch anyway" : "Dispatch train"}
+
+
+
+
);
}
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;