add loading confirmation for import-Djibouti trains before dispatch

This commit is contained in:
Marshal
2026-07-05 21:07:03 +00:00
parent 1433796db0
commit 3485a7d63d
8 changed files with 291 additions and 24 deletions

View File

@@ -453,6 +453,19 @@ export class TrainSchedulingController {
return this.trainSchedulingService.confirmImportLoadedOnTrain(id, dto); 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") @Post("schedules/:id/import-djibouti/depart")
@TrainSchedulingManage() @TrainSchedulingManage()
@ApiOperation({ summary: "Depart loaded import train from Djibouti" }) @ApiOperation({ summary: "Depart loaded import train from Djibouti" })

View File

@@ -1515,6 +1515,24 @@ export class TrainSchedulingService {
return this.getImportDjiboutiOperation(schedule.id); 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 = {}) { async departImportFromDjibouti(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId); const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
@@ -3931,6 +3949,18 @@ export class TrainSchedulingService {
const allocationIds = allocations.map((a) => a.id); const allocationIds = allocations.map((a) => a.id);
const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId)); 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 windowCfg = await this.getWindowConfig();
const [containerItems, bulkLoads] = await Promise.all([ const [containerItems, bulkLoads] = await Promise.all([
@@ -3964,6 +3994,8 @@ export class TrainSchedulingService {
freightType: this.resolveScheduleFreightType(schedule), freightType: this.resolveScheduleFreightType(schedule),
trainNumber: schedule.trainNumber ?? null, trainNumber: schedule.trainNumber ?? null,
direction: schedule.direction ?? null, direction: schedule.direction ?? null,
requiresLoadingConfirmation,
loadingConfirmed,
// Booking-window phase + phase deadlines drive the countdown timers in the // Booking-window phase + phase deadlines drive the countdown timers in the
// operations workspace (display only — the window engine enforces them). // operations workspace (display only — the window engine enforces them).
windowPhase: schedule.windowPhase ?? null, windowPhase: schedule.windowPhase ?? null,

View File

@@ -24,7 +24,7 @@ import {
Inbox, Inbox,
PackageCheck, PackageCheck,
PackageX, PackageX,
Repeat, // Repeat, // used by the hidden Move (reassign) button
Train, Train,
Weight, Weight,
X, X,
@@ -156,6 +156,9 @@ export function ScheduleWorkspacePanel({
const setLoading = useMutation( const setLoading = useMutation(
api.trainScheduling.setLoadingStatus.mutationOptions(), api.trainScheduling.setLoadingStatus.mutationOptions(),
); );
const confirmLoading = useMutation(
api.trainScheduling.confirmLoading.mutationOptions(),
);
const moveSchedule = useMutation( const moveSchedule = useMutation(
api.trainScheduling.moveBookingSchedule.mutationOptions(), 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 = () => { const doMove = () => {
if (!moveBookingId || !moveTarget) return; if (!moveBookingId || !moveTarget) return;
moveSchedule moveSchedule
@@ -376,6 +398,54 @@ export function ScheduleWorkspacePanel({
</Text> </Text>
) : null} ) : null}
{/* Loading confirmation — required before dispatch for import-Djibouti
trains; shown for every direction so staff have one place to confirm. */}
{canManage ? (
<Group
gap={10}
p="sm"
wrap="nowrap"
align="center"
justify="space-between"
style={{
borderRadius: 10,
background: schedule.loadingConfirmed
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-yellow-0)",
border: `1px solid ${
schedule.loadingConfirmed
? "var(--mantine-color-edr-green-2)"
: "var(--mantine-color-yellow-3)"
}`,
}}
>
<Group gap={8} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
{schedule.loadingConfirmed ? (
<CheckCircle2 size={18} color="var(--mantine-color-edr-green-7)" />
) : (
<PackageCheck size={18} color="#B7791F" />
)}
<Text size="sm" fw={600}>
{schedule.loadingConfirmed
? "Loading confirmed — cleared to dispatch"
: "Confirm loading before dispatching this train"}
</Text>
</Group>
{!schedule.loadingConfirmed ? (
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={14} />}
loading={confirmLoading.isPending}
onClick={doConfirmLoading}
>
Confirm loading
</Button>
) : null}
</Group>
) : null}
{/* Two-panel board */} {/* Two-panel board */}
<Group align="stretch" gap="lg" grow wrap="wrap"> <Group align="stretch" gap="lg" grow wrap="wrap">
{/* Pool */} {/* Pool */}
@@ -477,6 +547,7 @@ export function ScheduleWorkspacePanel({
</Button> </Button>
</Tooltip> </Tooltip>
) : null} ) : null}
{/* Reassign-to-another-train — hidden for now.
<Tooltip label="Reassign to another train" withArrow> <Tooltip label="Reassign to another train" withArrow>
<Button <Button
size="compact-sm" size="compact-sm"
@@ -492,6 +563,7 @@ export function ScheduleWorkspacePanel({
Move Move
</Button> </Button>
</Tooltip> </Tooltip>
*/}
<Tooltip label="Remove from this train" withArrow> <Tooltip label="Remove from this train" withArrow>
<Button <Button
size="compact-sm" size="compact-sm"

View File

@@ -330,6 +330,8 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/import-loading-status`, `/train-scheduling/schedules/${id}/import-loading-status`,
LOADING_STATUS: (id: string) => LOADING_STATUS: (id: string) =>
`/train-scheduling/schedules/${id}/loading-status`, `/train-scheduling/schedules/${id}/loading-status`,
CONFIRM_LOADING: (id: string) =>
`/train-scheduling/schedules/${id}/confirm-loading`,
IMPORT_DJIBOUTI: (id: string) => IMPORT_DJIBOUTI: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti`, `/train-scheduling/schedules/${id}/import-djibouti`,
IMPORT_DJIBOUTI_DOCUMENTS: (id: string) => IMPORT_DJIBOUTI_DOCUMENTS: (id: string) =>

View File

@@ -1,10 +1,13 @@
import { import {
Alert,
Badge, Badge,
Box, Box,
Button, Button,
Checkbox, Checkbox,
Group, Group,
List,
Loader, Loader,
Modal,
Paper, Paper,
RingProgress, RingProgress,
Stack, Stack,
@@ -15,6 +18,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { isAxiosError } from "axios"; import { isAxiosError } from "axios";
import { import {
AlertTriangle,
ArrowLeft, ArrowLeft,
CalendarClock, CalendarClock,
CheckCircle2, CheckCircle2,
@@ -44,7 +48,7 @@ import {
} from "@/components/trainScheduling/containerPlacement.util"; } from "@/components/trainScheduling/containerPlacement.util";
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid"; import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; // import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal"; import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel"; // import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
@@ -100,6 +104,7 @@ export default function TrainScheduleV2DetailPage() {
const [gatepassReference, setGatepassReference] = useState(""); const [gatepassReference, setGatepassReference] = useState("");
const [gatepassFileUrl, setGatepassFileUrl] = useState(""); const [gatepassFileUrl, setGatepassFileUrl] = useState("");
const [gatepassNotes, setGatepassNotes] = useState(""); const [gatepassNotes, setGatepassNotes] = useState("");
const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false);
const autoPreviewedRef = useRef(false); const autoPreviewedRef = useRef(false);
const detailQuery = useQuery( const detailQuery = useQuery(
@@ -148,12 +153,14 @@ export default function TrainScheduleV2DetailPage() {
}, },
}); });
const importLoadingQuery = useQuery( // Superseded by the Workspace tab Load/Unload toggle — see commented
api.trainScheduling.importLoadingBookings.queryOptions({ // "Import loading confirmation" card below.
input: { id: scheduleId ?? "" }, // const importLoadingQuery = useQuery(
enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"), // api.trainScheduling.importLoadingBookings.queryOptions({
}), // input: { id: scheduleId ?? "" },
); // enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"),
// }),
// );
const eligibleFilters = useMemo( const eligibleFilters = useMemo(
() => () =>
@@ -360,6 +367,22 @@ export default function TrainScheduleV2DetailPage() {
const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status); const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status);
const canFinalize = schedule.status === "DRAFT" && (schedule.bookings?.length ?? 0) > 0; const canFinalize = schedule.status === "DRAFT" && (schedule.bookings?.length ?? 0) > 0;
const canDispatch = schedule.status === "SCHEDULED"; const canDispatch = schedule.status === "SCHEDULED";
// Dispatch readiness: bookings with no wagon, and wagon-loaded bookings whose
// cargo staff never marked loaded. Both are warnings, not blockers — staff can
// still dispatch after confirming.
const dispatchBookings = schedule.bookings ?? [];
const unassignedCount = dispatchBookings.filter((b) => !b.wagonAssigned).length;
const unloadedCount = dispatchBookings.filter(
(b) => b.wagonAssigned && (b.loadingStatus ?? "UNLOADED") !== "LOADED",
).length;
// Import-Djibouti trains are HARD-blocked from dispatch until loading is
// confirmed in the workspace — surface it as a blocker, not just a warning.
const loadingBlocksDispatch =
schedule.requiresLoadingConfirmation === true &&
schedule.loadingConfirmed !== true;
const hasDispatchWarnings = unassignedCount > 0 || unloadedCount > 0;
const finalizeStep = hasContainerStep ? 3 : 2; const finalizeStep = hasContainerStep ? 3 : 2;
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status); const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
const canPrintMarshalling = const canPrintMarshalling =
@@ -398,6 +421,24 @@ export default function TrainScheduleV2DetailPage() {
} }
}; };
const runDispatch = async () => {
setDispatchConfirmOpen(false);
try {
await dispatch.mutateAsync(scheduleId);
await openMarshallingDocument({
title: "Train dispatched",
successDescription: "Marshalling document generated for the dispatched train.",
errorTitle: "Train dispatched, but document could not open",
});
} catch (err) {
toast({
title: "Dispatch failed",
description: parseError(err, "Could not dispatch"),
variant: "destructive",
});
}
};
const handleAssign = async () => { const handleAssign = async () => {
if (!allSelectedIds.length) return; if (!allSelectedIds.length) return;
@@ -779,22 +820,7 @@ export default function TrainScheduleV2DetailPage() {
radius="md" radius="md"
leftSection={<Send size={18} />} leftSection={<Send size={18} />}
loading={dispatch.isPending} loading={dispatch.isPending}
onClick={async () => { onClick={() => setDispatchConfirmOpen(true)}
try {
await dispatch.mutateAsync(scheduleId);
await openMarshallingDocument({
title: "Train dispatched",
successDescription: "Marshalling document generated for the dispatched train.",
errorTitle: "Train dispatched, but document could not open",
});
} catch (err) {
toast({
title: "Dispatch failed",
description: parseError(err, "Could not dispatch"),
variant: "destructive",
});
}
}}
> >
Dispatch train Dispatch train
</Button> </Button>
@@ -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" ? ( {schedule?.direction === "IMPORT" ? (
<Paper radius="xl" p="lg" withBorder> <Paper radius="xl" p="lg" withBorder>
<Stack gap="md"> <Stack gap="md">
@@ -1018,6 +1047,7 @@ export default function TrainScheduleV2DetailPage() {
</Stack> </Stack>
</Paper> </Paper>
) : null} ) : null}
*/}
<Tabs defaultValue="workflow" radius="md" color="edr-green" keepMounted={false}> <Tabs defaultValue="workflow" radius="md" color="edr-green" keepMounted={false}>
<Tabs.List mb="md"> <Tabs.List mb="md">
@@ -1119,6 +1149,102 @@ export default function TrainScheduleV2DetailPage() {
onClose={() => setWindowSettingsOpen(false)} onClose={() => setWindowSettingsOpen(false)}
onSaved={() => void detailQuery.refetch()} onSaved={() => void detailQuery.refetch()}
/> />
<Modal
opened={dispatchConfirmOpen}
onClose={() => setDispatchConfirmOpen(false)}
centered
radius="lg"
title={
<Group gap={8}>
<Send size={18} />
<Text fw={700}>Dispatch this train?</Text>
</Group>
}
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Dispatch locks the composition and begins rail movement. This cannot be
undone.
</Text>
{loadingBlocksDispatch ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertTriangle size={18} />}
title="Loading not confirmed"
>
This import train cannot depart until loading is confirmed. Use{" "}
<Text span fw={700}>
Confirm loading
</Text>{" "}
in the Workspace tab first.
</Alert>
) : null}
{hasDispatchWarnings ? (
<Alert
color="orange"
variant="light"
radius="md"
icon={<AlertTriangle size={18} />}
title="Some bookings are not fully ready"
>
<List size="sm" spacing={4}>
{unassignedCount > 0 ? (
<List.Item>
<Text span fw={700}>
{unassignedCount}
</Text>{" "}
booking{unassignedCount === 1 ? "" : "s"} not assigned to a wagon
</List.Item>
) : null}
{unloadedCount > 0 ? (
<List.Item>
<Text span fw={700}>
{unloadedCount}
</Text>{" "}
wagon-assigned booking{unloadedCount === 1 ? "" : "s"} still marked
unloaded
</List.Item>
) : null}
</List>
<Text size="xs" c="dimmed" mt={6}>
You can still dispatch confirm to proceed.
</Text>
</Alert>
) : (
<Alert
color="edr-green"
variant="light"
radius="md"
icon={<CheckCircle2 size={18} />}
>
All bookings are assigned to a wagon and marked loaded.
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setDispatchConfirmOpen(false)}
>
Cancel
</Button>
<Button
color="edr-green"
leftSection={<Send size={16} />}
loading={dispatch.isPending}
disabled={loadingBlocksDispatch}
onClick={() => void runDispatch()}
>
{hasDispatchWarnings ? "Dispatch anyway" : "Dispatch train"}
</Button>
</Group>
</Stack>
</Modal>
</PageContainer> </PageContainer>
); );
} }

View File

@@ -558,6 +558,14 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS, () => TRAIN_SCHEDULING_INVALIDATIONS,
), ),
confirmLoading: endpoint<{ id: string }, TrainScheduleDetail>(
"train-scheduling",
"confirm-loading",
({ id }) => trainSchedulingService.confirmLoading(id),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
pinWagons: endpoint< pinWagons: endpoint<
{ id: string; payload: PinWagonsPayload }, { id: string; payload: PinWagonsPayload },
TrainScheduleDetail TrainScheduleDetail

View File

@@ -369,6 +369,16 @@ export const trainSchedulingService = {
return unwrap(response.data); return unwrap(response.data);
}, },
confirmLoading: async (
scheduleId: string,
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.CONFIRM_LOADING(scheduleId),
{},
);
return unwrap(response.data);
},
getImportDjiboutiOperation: async ( getImportDjiboutiOperation: async (
scheduleId: string, scheduleId: string,
): Promise<ImportDjiboutiOperation> => { ): Promise<ImportDjiboutiOperation> => {

View File

@@ -429,6 +429,10 @@ export interface TrainScheduleDetail {
freightType?: FreightType | null; freightType?: FreightType | null;
trainNumber?: string | null; trainNumber?: string | null;
direction?: 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; windowPhase?: BookingWindowPhase | string | null;
windowOpensAt?: string | null; windowOpensAt?: string | null;
windowClosesAt?: string | null; windowClosesAt?: string | null;