From b4f2ec1c75c32665fa89daaab3b1cb87125eb9af Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 14 Aug 2026 09:57:20 +0000 Subject: [PATCH] feat(train-scheduling): load returned empties onto an export train Empties had no way onto a departure: the return record could name a train but nothing seated it on a wagon. Export schedules now expose a loading action that packs selected returns onto free wagons at one 40ft or two 20ft each, enforced both in the picker and in the API (existing empties on the schedule count against their wagon). Adds container_size, train_schedule_id and wagon_sequence_no to freight.empty_container_returns. --- .../3540000000000-EmptyReturnTrainLoad.ts | 35 +++ .../dto/import-operations.dto.ts | 55 +++- .../empty-container-wagon.util.spec.ts | 23 ++ .../empty-container-wagon.util.ts | 16 ++ .../entities/empty-container-return.entity.ts | 11 + .../import-operations.controller.ts | 9 + .../import-operations.service.ts | 62 ++++- .../LoadEmptyContainersModal.tsx | 249 ++++++++++++++++++ .../emptyContainerLoad.util.spec.ts | 60 +++++ .../emptyContainerLoad.util.ts | 54 ++++ .../backoffice/src/constants/URLS.ts | 2 + .../TrainScheduleV2DetailPage.tsx | 22 ++ .../pages/warehouses/ContainerReturnsPage.tsx | 5 + .../src/services/importOperations.service.ts | 11 + .../backoffice/src/types/importOperations.ts | 18 ++ 15 files changed, 630 insertions(+), 2 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3540000000000-EmptyReturnTrainLoad.ts create mode 100644 apps/edr-freight-api/src/modules/import-operations/empty-container-wagon.util.spec.ts create mode 100644 apps/edr-freight-api/src/modules/import-operations/empty-container-wagon.util.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/LoadEmptyContainersModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/emptyContainerLoad.util.spec.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/emptyContainerLoad.util.ts 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 ? (