diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index 70b42a9aa..a8e11d4cb 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -220,6 +220,15 @@ export class GlOperationsService { wagonAllocated, departedAt: departedAt ? new Date(departedAt).toISOString() : null, arrivedAt: arrivedAt ? new Date(arrivedAt).toISOString() : null, + // The train's own actuals, kept separate from the booking's journey + // above: a UI can say where the TRAIN is without implying this booking + // has been unloaded (which is what the clearance gates key on). + scheduleDepartedAt: schedule?.actualDepartureAt + ? new Date(schedule.actualDepartureAt).toISOString() + : null, + scheduleArrivedAt: schedule?.actualArrivalAt + ? new Date(schedule.actualArrivalAt).toISOString() + : null, }; } diff --git a/apps/edr-freight-web/portal/src/pages/forwarder/ForwarderClearancePanel.tsx b/apps/edr-freight-web/portal/src/pages/forwarder/ForwarderClearancePanel.tsx index 9d25b4809..85b063c36 100644 --- a/apps/edr-freight-web/portal/src/pages/forwarder/ForwarderClearancePanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/forwarder/ForwarderClearancePanel.tsx @@ -44,10 +44,27 @@ function isMilestoneDone(ms: MilestoneRow[] | undefined, code: string): boolean const formatStamp = (value: string): string => new Date(value).toLocaleString(); +/** + * When the TRAIN physically left / reached its terminal, as opposed to when + * THIS booking was loaded and unloaded. The booking's own stamps gate the + * clearance steps that follow (T1), but the train leg is about the train, so + * a booking still waiting to be unloaded must not make an arrived train read + * as in transit. Falls back to the booking's stamps on legacy rows that carry + * no schedule actuals. + */ +function trainDeparted(train: Freight.ClearanceView["train"] | undefined): string | null { + return train?.scheduleDepartedAt ?? train?.departedAt ?? null; +} + +function trainArrived(train: Freight.ClearanceView["train"] | undefined): string | null { + return train?.scheduleArrivedAt ?? train?.arrivedAt ?? null; +} + function trainLegDescription(train: Freight.ClearanceView["train"] | undefined): string { - if (train?.arrivedAt) return `Arrived ${formatStamp(train.arrivedAt)}`; - if (train?.departedAt) return `Departed ${formatStamp(train.departedAt)} · in transit`; - return "Departure and arrival"; + const arrived = trainArrived(train); + if (arrived) return `Arrived ${formatStamp(arrived)}`; + if (trainDeparted(train)) return "In transit — completes on arrival"; + return "Arrival at Djibouti"; } const OWNER_LABEL: Record = { @@ -260,16 +277,28 @@ export function ForwarderClearancePanel({ done: done("EXPORT_TRANSPORT_ISSUED"), }, { - key: "train", - label: "Train to Djibouti", + key: "departed", + label: "Train departs to Djibouti", + description: trainDeparted(clearance.train) + ? `Departed ${formatStamp(trainDeparted(clearance.train)!)}` + : "Departure from the loading yard", + owner: "system", + done: Boolean(trainDeparted(clearance.train) || trainArrived(clearance.train)), + }, + { + key: "arrived", + label: "Train arrives at Djibouti", description: trainLegDescription(clearance.train), owner: "system", - done: Boolean(clearance.train?.arrivedAt), + done: Boolean(trainArrived(clearance.train)), }, { key: "t1close", label: "Accept T1", - description: "The Djibouti agent closes once the train arrives", + description: + trainArrived(clearance.train) && !clearance.train?.arrivedAt + ? "Unlocks once the cargo is unloaded at Djibouti" + : "The Djibouti agent closes once the train arrives", owner: "djibouti", done: Boolean(clearance.t1Closed), }, diff --git a/apps/edr-freight-web/portal/src/pages/transit-agent/TransitClearanceActionPanel.tsx b/apps/edr-freight-web/portal/src/pages/transit-agent/TransitClearanceActionPanel.tsx index 9c292f1c0..6ad103350 100644 --- a/apps/edr-freight-web/portal/src/pages/transit-agent/TransitClearanceActionPanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/transit-agent/TransitClearanceActionPanel.tsx @@ -47,16 +47,31 @@ function isMilestoneDone( const formatStamp = (value: string): string => new Date(value).toLocaleString(); /** - * The rail leg's own stamps, so a train that has left reads as in transit - * rather than untouched — the step only completes on arrival. + * The TRAIN's own actuals, as opposed to THIS booking's journey (loaded at, + * unloaded at its own destination). The rail leg is about the train, so an + * arrived train must not read as in transit merely because this booking has + * not been unloaded yet. Falls back to the booking's stamps on legacy rows + * that carry no schedule actuals. */ +function trainDeparted( + train: Freight.ClearanceView["train"] | undefined, +): string | null { + return train?.scheduleDepartedAt ?? train?.departedAt ?? null; +} + +function trainArrived( + train: Freight.ClearanceView["train"] | undefined, +): string | null { + return train?.scheduleArrivedAt ?? train?.arrivedAt ?? null; +} + function trainLegDescription( train: Freight.ClearanceView["train"] | undefined, ): string { - if (train?.arrivedAt) return `Arrived ${formatStamp(train.arrivedAt)}`; - if (train?.departedAt) - return `Departed ${formatStamp(train.departedAt)} · in transit`; - return "Departure and arrival"; + const arrived = trainArrived(train); + if (arrived) return `Arrived ${formatStamp(arrived)}`; + if (trainDeparted(train)) return "In transit — completes on arrival"; + return "Arrival at Djibouti"; } /** @@ -238,13 +253,25 @@ export function TransitClearanceActionPanel({ done: bookingDone("EXPORT_TRANSPORT_ISSUED"), }, { - label: "Train to Djibouti", + label: "Train departs to Djibouti", + description: trainDeparted(clearance?.train) + ? `Departed ${formatStamp(trainDeparted(clearance?.train)!)}` + : "Departure from the loading yard", + done: Boolean( + trainDeparted(clearance?.train) || trainArrived(clearance?.train), + ), + }, + { + label: "Train arrives at Djibouti", description: trainLegDescription(clearance?.train), - done: Boolean(clearance?.train?.arrivedAt), + done: Boolean(trainArrived(clearance?.train)), }, { label: "Accept T1", - description: "You close the T1 once the train arrives", + description: + trainArrived(clearance?.train) && !clearance?.train?.arrivedAt + ? "Unlocks once the cargo is unloaded at Djibouti" + : "You close the T1 once the train arrives", done: Boolean(clearance?.t1Closed), }, { diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 8dc666070..24ea04274 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -412,8 +412,21 @@ export interface ClearanceT1State { export interface ClearanceTrainState { scheduleId: string | null; wagonAllocated: boolean; + /** + * The BOOKING's own journey: when it was loaded, and when it was unloaded at + * its own destination. A booking that alights mid-corridor arrives while the + * train rolls on, and one still on board has not arrived even though the + * train has — so clearance gates (T1) key on these, not on the train's. + */ departedAt: string | null; arrivedAt: string | null; + /** + * The TRAIN's own actuals, for showing where the train physically is. Set + * once the schedule is dispatched / marked arrived, regardless of whether + * this booking has been unloaded yet. + */ + scheduleDepartedAt?: string | null; + scheduleArrivedAt?: string | null; } /**