diff --git a/apps/edr-freight-api/src/migrations/3540000000000-EmptyReturnTrainLoad.ts b/apps/edr-freight-api/src/migrations/3540000000000-EmptyReturnTrainLoad.ts new file mode 100644 index 000000000..55ef08262 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3540000000000-EmptyReturnTrainLoad.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Empty containers ride an export departure back to Djibouti, so a return now + * records which train schedule carries it and on which wagon slot. Size is + * captured too: the wagon rule is one 40ft OR two 20ft per wagon, which cannot + * be enforced without knowing the box size. + */ +export class EmptyReturnTrainLoad3540000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.empty_container_returns + ADD COLUMN IF NOT EXISTS container_size character varying(10), + ADD COLUMN IF NOT EXISTS train_schedule_id uuid, + ADD COLUMN IF NOT EXISTS wagon_sequence_no integer + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_empty_container_returns_train_schedule_id + ON freight.empty_container_returns (train_schedule_id) + WHERE train_schedule_id IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_empty_container_returns_train_schedule_id + `); + await queryRunner.query(` + ALTER TABLE freight.empty_container_returns + DROP COLUMN IF EXISTS container_size, + DROP COLUMN IF EXISTS train_schedule_id, + DROP COLUMN IF EXISTS wagon_sequence_no + `); + } +} diff --git a/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts b/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts index b3ec979b4..9af97f1e8 100644 --- a/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts +++ b/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts @@ -1,5 +1,17 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsArray, IsDateString, IsIn, IsOptional, IsString, IsUUID } from 'class-validator'; +import { Type } from 'class-transformer'; +import { + ArrayNotEmpty, + IsArray, + IsDateString, + IsIn, + IsInt, + IsOptional, + IsString, + IsUUID, + Min, + ValidateNested, +} from 'class-validator'; import { DJIBOUTI_INCIDENT_TYPES, type DjiboutiIncidentType } from '../entities/djibouti-incident.entity'; import { @@ -120,6 +132,9 @@ export class ImportOperationActionDto { notes?: string; } +export const EMPTY_CONTAINER_SIZES = ['20', '40'] as const; +export type EmptyContainerSize = (typeof EMPTY_CONTAINER_SIZES)[number]; + export class CreateEmptyContainerReturnDto { @ApiProperty() @IsString() @@ -140,6 +155,11 @@ export class CreateEmptyContainerReturnDto { @IsDateString() returnDate?: string; + @ApiPropertyOptional({ enum: EMPTY_CONTAINER_SIZES }) + @IsOptional() + @IsIn(EMPTY_CONTAINER_SIZES) + containerSize?: EmptyContainerSize; + @ApiPropertyOptional() @IsOptional() @IsString() @@ -176,6 +196,39 @@ export class CreateEmptyContainerReturnDto { returnedBy?: 'EDR' | 'CUSTOMER'; } +export class LoadEmptyContainerItemDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + id!: string; + + @ApiProperty({ enum: EMPTY_CONTAINER_SIZES }) + @IsIn(EMPTY_CONTAINER_SIZES) + containerSize!: EmptyContainerSize; + + @ApiProperty() + @IsInt() + @Min(1) + wagonSequenceNo!: number; +} + +export class LoadEmptyContainersOnTrainDto extends ImportOperationActionDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + trainScheduleId!: string; + + @ApiPropertyOptional({ description: 'Run number shown on the return record.' }) + @IsOptional() + @IsString() + trainNumber?: string; + + @ApiProperty({ type: [LoadEmptyContainerItemDto] }) + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => LoadEmptyContainerItemDto) + items!: LoadEmptyContainerItemDto[]; +} + export class UpdateEmptyContainerReturnStatusDto extends ImportOperationActionDto { @ApiProperty({ enum: EMPTY_CONTAINER_RETURN_STATUSES }) @IsIn(EMPTY_CONTAINER_RETURN_STATUSES) diff --git a/apps/edr-freight-api/src/modules/import-operations/empty-container-wagon.util.spec.ts b/apps/edr-freight-api/src/modules/import-operations/empty-container-wagon.util.spec.ts new file mode 100644 index 000000000..34fcece4c --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/empty-container-wagon.util.spec.ts @@ -0,0 +1,23 @@ +import { assertWagonLoad } from './empty-container-wagon.util'; + +describe('assertWagonLoad', () => { + it('accepts one 40ft or two 20ft per wagon', () => { + expect(() => + assertWagonLoad( + new Map([ + [1, ['40']], + [2, ['20', '20']], + [3, ['20']], + ]), + ), + ).not.toThrow(); + }); + + it('rejects a 40ft sharing a wagon', () => { + expect(() => assertWagonLoad(new Map([[4, ['40', '20']]]))).toThrow(/Wagon 4/); + }); + + it('rejects three containers on a wagon', () => { + expect(() => assertWagonLoad(new Map([[5, ['20', '20', '20']]]))).toThrow(/Wagon 5/); + }); +}); diff --git a/apps/edr-freight-api/src/modules/import-operations/empty-container-wagon.util.ts b/apps/edr-freight-api/src/modules/import-operations/empty-container-wagon.util.ts new file mode 100644 index 000000000..bc82db3ee --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/empty-container-wagon.util.ts @@ -0,0 +1,16 @@ +import { BadRequestException } from '@nestjs/common'; + +/** + * A wagon carries ONE 40ft OR TWO 20ft empties — never a mix, never three. + * Throws on the first wagon that breaks the rule. + */ +export function assertWagonLoad(sizesByWagon: Map): void { + for (const [wagon, sizes] of sizesByWagon) { + const has40 = sizes.some((size) => size === '40'); + if ((has40 && sizes.length > 1) || sizes.length > 2) { + throw new BadRequestException( + `Wagon ${wagon} takes one 40ft or two 20ft containers — got ${sizes.join('ft + ')}ft`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts b/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts index 2538a204b..263dd30d2 100644 --- a/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts +++ b/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts @@ -51,6 +51,17 @@ export class EmptyContainerReturn extends BaseEntity { @Column({ name: 'wagon_allocation_reference', type: 'varchar', length: 120, nullable: true }) wagonAllocationReference?: string | null; + /** '20' or '40' — drives the one-40ft-or-two-20ft-per-wagon loading rule. */ + @Column({ name: 'container_size', type: 'varchar', length: 10, nullable: true }) + containerSize?: string | null; + + /** Export departure carrying this empty back to Djibouti. */ + @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) + trainScheduleId?: string | null; + + @Column({ name: 'wagon_sequence_no', type: 'int', nullable: true }) + wagonSequenceNo?: number | null; + @Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true }) performedBy?: string | null; diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts index 15e5404a7..c631bef92 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts @@ -8,6 +8,7 @@ import { CreateDjiboutiIncidentDto, CreateEmptyContainerReturnDto, ImportOperationActionDto, + LoadEmptyContainersOnTrainDto, RecordDeclarationDto, UpdateEmptyContainerReturnStatusDto, UploadImportCustomsDocumentDto, @@ -104,6 +105,14 @@ export class ImportOperationsController { return this.service.createEmptyReturn(dto); } + @Post('empty-container-returns/load-on-train') + @ApiOperation({ + summary: 'Load returned empties onto an export train (1×40ft or 2×20ft per wagon)', + }) + loadEmptyReturnsOnTrain(@Body() dto: LoadEmptyContainersOnTrainDto) { + return this.service.loadEmptyReturnsOnTrain(dto); + } + @Post('empty-container-returns/:id/status') @ApiOperation({ summary: 'Batch 16: advance empty container return workflow' }) updateEmptyReturnStatus( diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts index bd40e2ab1..28eb4e44f 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts @@ -1,11 +1,12 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { CreateDjiboutiIncidentDto, CreateEmptyContainerReturnDto, ImportOperationActionDto, + LoadEmptyContainersOnTrainDto, RecordDeclarationDto, AssignCustomsRiskDto, UpdateEmptyContainerReturnStatusDto, @@ -15,6 +16,7 @@ import { DjiboutiIncident, type DjiboutiIncidentType, } from './entities/djibouti-incident.entity'; +import { assertWagonLoad } from './empty-container-wagon.util'; import { EmptyContainerReturn } from './entities/empty-container-return.entity'; import { ImportCustomsFinalization, @@ -156,6 +158,7 @@ export class ImportOperationsService { bookingId: dto.bookingId ?? null, customerId: dto.customerId ?? null, returnDate, + containerSize: dto.containerSize ?? null, facility: dto.facility ?? null, yard: dto.yard ?? null, zone: dto.zone ?? null, @@ -170,6 +173,63 @@ export class ImportOperationsService { ); } + /** + * Load returned empties onto an export departure. A wagon takes ONE 40ft or + * TWO 20ft — never a mix, never three. Empties already sitting on a wagon of + * the same schedule count against that wagon, so incremental loads cannot + * quietly double-book a slot. + * + * ponytail: does not check the wagon is free of cargo bookings — the loading + * UI picks only unallocated wagons from the schedule's plan. Cross-check here + * if empties ever get loaded from another client. + */ + async loadEmptyReturnsOnTrain(dto: LoadEmptyContainersOnTrainDto) { + const ids = dto.items.map((item) => item.id); + const rows = await this.emptyReturns.find({ where: { id: In(ids) } }); + const missing = ids.filter((id) => !rows.some((row) => row.id === id)); + if (missing.length) { + throw new NotFoundException(`Empty container return(s) not found: ${missing.join(', ')}`); + } + + const alreadyOnTrain = await this.emptyReturns.find({ + where: { trainScheduleId: dto.trainScheduleId }, + }); + const byWagon = new Map(); + for (const row of alreadyOnTrain) { + if (row.wagonSequenceNo == null || ids.includes(row.id)) continue; + byWagon.set(row.wagonSequenceNo, [ + ...(byWagon.get(row.wagonSequenceNo) ?? []), + row.containerSize ?? '40', + ]); + } + for (const item of dto.items) { + byWagon.set(item.wagonSequenceNo, [ + ...(byWagon.get(item.wagonSequenceNo) ?? []), + item.containerSize, + ]); + } + assertWagonLoad(byWagon); + + const changedAt = new Date().toISOString(); + for (const item of dto.items) { + const row = rows.find((candidate) => candidate.id === item.id)!; + await this.emptyReturns.update(item.id, { + status: 'WAGON_ALLOCATED', + containerSize: item.containerSize, + trainScheduleId: dto.trainScheduleId, + wagonSequenceNo: item.wagonSequenceNo, + wagonAllocationReference: dto.trainNumber ?? dto.trainScheduleId, + performedBy: dto.performedBy ?? row.performedBy ?? null, + statusHistory: [ + ...(row.statusHistory ?? []), + { status: 'WAGON_ALLOCATED' as const, changedAt, performedBy: dto.performedBy ?? null }, + ], + }); + } + + return this.emptyReturns.find({ where: { trainScheduleId: dto.trainScheduleId } }); + } + async updateEmptyReturnStatus(id: string, dto: UpdateEmptyContainerReturnStatusDto) { const row = await this.emptyReturns.findOne({ where: { id } }); if (!row) { diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LoadEmptyContainersModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LoadEmptyContainersModal.tsx new file mode 100644 index 000000000..eadd18f85 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LoadEmptyContainersModal.tsx @@ -0,0 +1,249 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Alert, + Badge, + Button, + Checkbox, + Group, + Loader, + Modal, + SegmentedControl, + Stack, + Table, + Text, +} from "@mantine/core"; + +import { useToast } from "@/hooks/use-toast"; +import { importOperationsService } from "@/services/importOperations.service"; +import type { + EmptyContainerReturn, + EmptyContainerSize, +} from "@/types/importOperations"; +import type { TrainScheduleDetail } from "@/types/trainScheduling"; +import { packEmptiesOntoWagons, wagonsNeeded } from "./emptyContainerLoad.util"; + +/** Empties still on the ground — past these the box has already left the yard. */ +const LOADABLE_STATUSES = ["RETURNED", "ASSIGNED_STORAGE", "DOCUMENTATION_CLEARED"]; + +interface LoadEmptyContainersModalProps { + opened: boolean; + onClose: () => void; + schedule: TrainScheduleDetail; +} + +/** + * Loads returned empty containers onto an export departure. Wagons are filled + * one 40ft OR two 20ft each (see `packEmptiesOntoWagons`), drawing only on + * wagons of this train that carry no cargo booking and no empty already. + */ +export function LoadEmptyContainersModal({ + opened, + onClose, + schedule, +}: LoadEmptyContainersModalProps) { + const { toast } = useToast(); + const qc = useQueryClient(); + const [selected, setSelected] = useState([]); + const [sizeOverrides, setSizeOverrides] = useState>({}); + + const returnsQuery = useQuery({ + queryKey: ["empty-container-returns"], + queryFn: () => importOperationsService.listEmptyReturns(), + enabled: opened, + }); + + const returns = returnsQuery.data ?? []; + const loaded = useMemo( + () => returns.filter((ret) => ret.trainScheduleId === schedule.id), + [returns, schedule.id], + ); + const available = useMemo( + () => + returns.filter( + (ret) => !ret.trainScheduleId && LOADABLE_STATUSES.includes(ret.status), + ), + [returns], + ); + + const sizeOf = (ret: EmptyContainerReturn): EmptyContainerSize => + sizeOverrides[ret.id] ?? (ret.containerSize === "20" ? "20" : "40"); + + // A wagon is up for grabs when no booking rides it and no empty sits on it. + const freeWagons = useMemo(() => { + const takenByEmpties = new Set( + loaded.map((ret) => ret.wagonSequenceNo).filter((no): no is number => no != null), + ); + return (schedule.trainSet?.wagons ?? []) + .filter((wagon) => !wagon.allocations?.length && !takenByEmpties.has(wagon.sequenceNo)) + .map((wagon) => wagon.sequenceNo) + .sort((a, b) => a - b); + }, [schedule.trainSet?.wagons, loaded]); + + const picks = useMemo( + () => + available + .filter((ret) => selected.includes(ret.id)) + .map((ret) => ({ id: ret.id, containerSize: sizeOf(ret) })), + // eslint-disable-next-line react-hooks/exhaustive-deps + [available, selected, sizeOverrides], + ); + const needed = wagonsNeeded(picks); + const { assignments, unplaced } = packEmptiesOntoWagons(picks, freeWagons); + + const load = useMutation({ + mutationFn: () => + importOperationsService.loadEmptyContainersOnTrain({ + trainScheduleId: schedule.id, + trainNumber: schedule.trainNumber ?? undefined, + items: assignments, + }), + onSuccess: () => { + toast({ title: `${assignments.length} empty container(s) loaded` }); + qc.invalidateQueries({ queryKey: ["empty-container-returns"] }); + qc.invalidateQueries({ queryKey: ["train-scheduling"] }); + setSelected([]); + onClose(); + }, + onError: (error: any) => { + toast({ + variant: "destructive", + title: "Failed to load empty containers", + description: error?.response?.data?.message || error?.message, + }); + }, + }); + + return ( + + + + One 40ft or two 20ft containers per wagon. {freeWagons.length} free wagon + {freeWagons.length === 1 ? "" : "s"} on this train. + + + {loaded.length > 0 ? ( + + + {loaded.map((ret) => ( + + {ret.containerNumber} · wagon {ret.wagonSequenceNo ?? "—"} + + ))} + + + ) : null} + + {returnsQuery.isLoading ? ( + + + + ) : available.length === 0 ? ( + + No returned empty containers are waiting — record returns in Container Returns. + + ) : ( + + + + + + Container + Size + Facility + Returned + Status + + + + {available.map((ret) => { + const checked = selected.includes(ret.id); + return ( + + + + setSelected( + event.currentTarget.checked + ? [...selected, ret.id] + : selected.filter((id) => id !== ret.id), + ) + } + /> + + + + {ret.containerNumber} + + + + {/* Legacy returns carry no size — the operator sets it here + because the wagon rule cannot be applied without it. */} + + setSizeOverrides({ + ...sizeOverrides, + [ret.id]: value as EmptyContainerSize, + }) + } + data={[ + { label: "20ft", value: "20" }, + { label: "40ft", value: "40" }, + ]} + /> + + {ret.facility ?? "—"} + + {ret.returnDate ? new Date(ret.returnDate).toLocaleDateString() : "—"} + + + + {ret.status} + + + + ); + })} + +
+
+ )} + + {unplaced.length > 0 ? ( + + {needed} wagon(s) needed but only {freeWagons.length} free — unselect{" "} + {unplaced.length} container(s) or add wagons to the consist. + + ) : picks.length > 0 ? ( + + {picks.length} container(s) → wagons{" "} + {[...new Set(assignments.map((a) => a.wagonSequenceNo))].join(", ")} + + ) : null} + + + + + +
+
+ ); +} + +export default LoadEmptyContainersModal; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/emptyContainerLoad.util.spec.ts b/apps/edr-freight-web/backoffice/src/components/trainScheduling/emptyContainerLoad.util.spec.ts new file mode 100644 index 000000000..9094f3139 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/emptyContainerLoad.util.spec.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { packEmptiesOntoWagons, wagonsNeeded, type EmptyLoadPick } from './emptyContainerLoad.util'; + +const pick = (id: string, containerSize: '20' | '40'): EmptyLoadPick => ({ id, containerSize }); + +describe('emptyContainerLoad.util', () => { + it('gives each 40ft its own wagon', () => { + const { assignments, unplaced } = packEmptiesOntoWagons( + [pick('a', '40'), pick('b', '40')], + [1, 2, 3], + ); + expect(unplaced).toEqual([]); + expect(assignments.map((a) => a.wagonSequenceNo)).toEqual([1, 2]); + }); + + it('pairs 20ft two to a wagon, last odd one alone', () => { + const { assignments } = packEmptiesOntoWagons( + [pick('a', '20'), pick('b', '20'), pick('c', '20')], + [4, 5], + ); + expect(assignments.map((a) => [a.id, a.wagonSequenceNo])).toEqual([ + ['a', 4], + ['b', 4], + ['c', 5], + ]); + }); + + it('never mixes a 40ft and a 20ft on one wagon', () => { + const { assignments } = packEmptiesOntoWagons( + [pick('a', '20'), pick('b', '40'), pick('c', '20')], + [1, 2], + ); + const bySizeOnWagon = new Map(); + for (const a of assignments) { + bySizeOnWagon.set(a.wagonSequenceNo, [ + ...(bySizeOnWagon.get(a.wagonSequenceNo) ?? []), + a.containerSize, + ]); + } + for (const sizes of bySizeOnWagon.values()) { + expect(sizes.includes('40') ? sizes.length : 0).toBeLessThan(2); + expect(sizes.length).toBeLessThanOrEqual(2); + } + }); + + it('reports picks that ran out of wagons instead of dropping them', () => { + const { assignments, unplaced } = packEmptiesOntoWagons( + [pick('a', '40'), pick('b', '40'), pick('c', '20'), pick('d', '20')], + [7], + ); + expect(assignments).toHaveLength(1); + expect(unplaced.map((p) => p.id)).toEqual(['b', 'c', 'd']); + }); + + it('counts wagons needed', () => { + expect(wagonsNeeded([])).toBe(0); + expect(wagonsNeeded([pick('a', '40'), pick('b', '20'), pick('c', '20')])).toBe(2); + expect(wagonsNeeded([pick('a', '20')])).toBe(1); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/emptyContainerLoad.util.ts b/apps/edr-freight-web/backoffice/src/components/trainScheduling/emptyContainerLoad.util.ts new file mode 100644 index 000000000..588fe7649 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/emptyContainerLoad.util.ts @@ -0,0 +1,54 @@ +import type { EmptyContainerSize } from "@/types/importOperations"; + +export interface EmptyLoadPick { + id: string; + containerSize: EmptyContainerSize; +} + +export interface EmptyLoadAssignment extends EmptyLoadPick { + wagonSequenceNo: number; +} + +/** + * Fill wagons with the picked empties: a wagon takes ONE 40ft or TWO 20ft, + * never a mix. 40ft boxes are seated first so a half-filled 20ft wagon can + * never block them, and the 20s pair up behind them. + * + * `freeWagons` is the caller's ordered list of wagon sequence numbers with no + * cargo allocation. Returns the assignments that fit plus the picks that had + * no wagon left — the caller surfaces the shortfall instead of silently + * dropping boxes. + */ +export function packEmptiesOntoWagons( + picks: EmptyLoadPick[], + freeWagons: number[], +): { assignments: EmptyLoadAssignment[]; unplaced: EmptyLoadPick[] } { + const forty = picks.filter((pick) => pick.containerSize === "40"); + const twenty = picks.filter((pick) => pick.containerSize === "20"); + + const assignments: EmptyLoadAssignment[] = []; + const unplaced: EmptyLoadPick[] = []; + const wagons = [...freeWagons]; + + for (const pick of forty) { + const wagon = wagons.shift(); + if (wagon == null) unplaced.push(pick); + else assignments.push({ ...pick, wagonSequenceNo: wagon }); + } + + for (let index = 0; index < twenty.length; index += 2) { + const pair = twenty.slice(index, index + 2); + const wagon = wagons.shift(); + if (wagon == null) unplaced.push(...pair); + else assignments.push(...pair.map((pick) => ({ ...pick, wagonSequenceNo: wagon }))); + } + + return { assignments, unplaced }; +} + +/** Wagons the picks consume, whether or not enough are free. */ +export function wagonsNeeded(picks: EmptyLoadPick[]): number { + const forty = picks.filter((pick) => pick.containerSize === "40").length; + const twenty = picks.length - forty; + return forty + Math.ceil(twenty / 2); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index fd115e97d..2a9f40ebd 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -733,6 +733,8 @@ export const URL_CONSTANTS = { EMPTY_CONTAINER_RETURNS: "/import-operations/empty-container-returns", EMPTY_CONTAINER_RETURN_STATUS: (id: string) => `/import-operations/empty-container-returns/${id}/status`, + EMPTY_CONTAINER_RETURNS_LOAD_ON_TRAIN: + "/import-operations/empty-container-returns/load-on-train", }, VEHICLES: { diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index acfc3c375..ef9171d0d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -57,6 +57,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel"; import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel"; +import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal"; import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal"; import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel"; // import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; @@ -126,6 +127,7 @@ export default function TrainScheduleV2DetailPage() { const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false); const [switchTarget, setSwitchTarget] = useState(null); const [visualization3DOpen, setVisualization3DOpen] = useState(false); + const [loadEmptiesOpen, setLoadEmptiesOpen] = useState(false); const autoPreviewedRef = useRef(false); const detailQuery = useQuery( @@ -967,6 +969,20 @@ export default function TrainScheduleV2DetailPage() { Merge ) : null} + {/* Empties ride an export departure back to Djibouti — offered only + while the train can still take load. */} + {schedule.direction === "EXPORT" && + ["DRAFT", "SCHEDULED"].includes(schedule.status) ? ( + + ) : null} {(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (