Merge pull request #1290 from Tria-plc/eims-integration

feat(train-scheduling): load returned empties onto an export train
This commit is contained in:
Hagernesh Tadesse
2026-08-14 14:18:31 +03:00
committed by GitHub
15 changed files with 630 additions and 2 deletions

View File

@@ -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<void> {
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<void> {
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
`);
}
}

View File

@@ -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)

View File

@@ -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/);
});
});

View File

@@ -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<number, string[]>): 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`,
);
}
}
}

View File

@@ -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;

View File

@@ -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(

View File

@@ -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<number, string[]>();
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) {

View File

@@ -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<string[]>([]);
const [sizeOverrides, setSizeOverrides] = useState<Record<string, EmptyContainerSize>>({});
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 (
<Modal
opened={opened}
onClose={onClose}
title="Load Empty Containers"
size="xl"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
One 40ft or two 20ft containers per wagon. {freeWagons.length} free wagon
{freeWagons.length === 1 ? "" : "s"} on this train.
</Text>
{loaded.length > 0 ? (
<Alert color="gray" title={`${loaded.length} empty container(s) already loaded`}>
<Group gap="xs">
{loaded.map((ret) => (
<Badge key={ret.id} size="sm" variant="light">
{ret.containerNumber} · wagon {ret.wagonSequenceNo ?? "—"}
</Badge>
))}
</Group>
</Alert>
) : null}
{returnsQuery.isLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : available.length === 0 ? (
<Alert color="gray">
No returned empty containers are waiting record returns in Container Returns.
</Alert>
) : (
<Table.ScrollContainer minWidth={700}>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th w={40} />
<Table.Th>Container</Table.Th>
<Table.Th>Size</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Returned</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{available.map((ret) => {
const checked = selected.includes(ret.id);
return (
<Table.Tr key={ret.id}>
<Table.Td>
<Checkbox
checked={checked}
onChange={(event) =>
setSelected(
event.currentTarget.checked
? [...selected, ret.id]
: selected.filter((id) => id !== ret.id),
)
}
/>
</Table.Td>
<Table.Td>
<Text fw={600} size="sm">
{ret.containerNumber}
</Text>
</Table.Td>
<Table.Td>
{/* Legacy returns carry no size — the operator sets it here
because the wagon rule cannot be applied without it. */}
<SegmentedControl
size="xs"
value={sizeOf(ret)}
onChange={(value) =>
setSizeOverrides({
...sizeOverrides,
[ret.id]: value as EmptyContainerSize,
})
}
data={[
{ label: "20ft", value: "20" },
{ label: "40ft", value: "40" },
]}
/>
</Table.Td>
<Table.Td>{ret.facility ?? "—"}</Table.Td>
<Table.Td>
{ret.returnDate ? new Date(ret.returnDate).toLocaleDateString() : "—"}
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light">
{ret.status}
</Badge>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
{unplaced.length > 0 ? (
<Alert color="red">
{needed} wagon(s) needed but only {freeWagons.length} free unselect{" "}
{unplaced.length} container(s) or add wagons to the consist.
</Alert>
) : picks.length > 0 ? (
<Text size="sm">
{picks.length} container(s) wagons{" "}
{[...new Set(assignments.map((a) => a.wagonSequenceNo))].join(", ")}
</Text>
) : null}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={load.isPending}>
Cancel
</Button>
<Button
disabled={!picks.length || unplaced.length > 0}
loading={load.isPending}
onClick={() => load.mutate()}
>
Load {picks.length || ""} on train
</Button>
</Group>
</Stack>
</Modal>
);
}
export default LoadEmptyContainersModal;

View File

@@ -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<number, string[]>();
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);
});
});

View File

@@ -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);
}

View File

@@ -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: {

View File

@@ -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<EligibleContainerBooking | null>(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
</Button>
) : 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) ? (
<Button
variant="light"
color="edr-green"
size="compact-sm"
leftSection={<ContainerIcon size={14} />}
onClick={() => setLoadEmptiesOpen(true)}
>
Load Empty Container
</Button>
) : null}
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
<Button
variant="gradient"
@@ -1365,6 +1381,12 @@ export default function TrainScheduleV2DetailPage() {
onSaved={() => void detailQuery.refetch()}
/>
<LoadEmptyContainersModal
opened={loadEmptiesOpen}
onClose={() => setLoadEmptiesOpen(false)}
schedule={schedule}
/>
<MergeScheduleTrainModal
scheduleId={scheduleId ?? null}
currentTrainId={schedule.trainSet?.trainId ?? null}

View File

@@ -36,6 +36,7 @@ import { importOperationsService } from "@/services/importOperations.service";
import type {
EmptyContainerReturn,
EmptyContainerReturnStatus,
EmptyContainerSize,
} from "@/types/importOperations";
import type { TrainScheduleListItem } from "@/types/trainScheduling";
import { formatDateTime } from "@/lib/format";
@@ -267,6 +268,7 @@ export default function ContainerReturnsPage() {
returnType: "EDR" | "CUSTOMER";
containers: Array<{
containerNumber: string;
containerSize?: EmptyContainerSize;
returnDate: string;
warehouse: string;
condition?: string;
@@ -279,6 +281,7 @@ export default function ContainerReturnsPage() {
for (const container of truck.containers) {
const result = await importOperationsService.createEmptyReturn({
containerNumber: container.containerNumber,
containerSize: container.containerSize,
returnDate: new Date(container.returnDate).toISOString(),
bookingId: truck.bookingId,
customerId: truck.customerId ?? undefined,
@@ -844,6 +847,8 @@ function ContainerReturnModal({ opened, onClose, group, onSubmit, loading }: Con
.filter((c) => selectedContainers.includes(c.key))
.map((c) => ({
containerNumber: c.containerNumber,
// Inventory records "20ft"/"40ft"; the wagon rule only needs the number.
containerSize: c.size?.includes("40") ? ("40" as const) : c.size ? ("20" as const) : undefined,
returnDate,
warehouse: selectedWarehouse?.name || warehouse,
condition: condition || undefined,

View File

@@ -5,6 +5,7 @@ import type {
AssignCustomsRiskPayload,
CreateDjiboutiIncidentPayload,
CreateEmptyContainerReturnPayload,
LoadEmptyContainersOnTrainPayload,
DjiboutiIncident,
EmptyContainerReturn,
ImportCustomsFinalization,
@@ -123,6 +124,16 @@ export const importOperationsService = {
return unwrap(response.data);
},
loadEmptyContainersOnTrain: async (
payload: LoadEmptyContainersOnTrainPayload,
): Promise<EmptyContainerReturn[]> => {
const response = await client.post<EmptyContainerReturn[]>(
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURNS_LOAD_ON_TRAIN,
payload,
);
return unwrap(response.data);
},
updateEmptyReturnStatus: async (
id: string,
payload: UpdateEmptyContainerReturnStatusPayload,

View File

@@ -101,6 +101,11 @@ export interface EmptyContainerReturn {
handoverNote: string | null;
status: EmptyContainerReturnStatus;
wagonAllocationReference: string | null;
/** '20' or '40' — one 40ft OR two 20ft ride a wagon. */
containerSize: EmptyContainerSize | null;
/** Export departure this empty is loaded on, once allocated. */
trainScheduleId: string | null;
wagonSequenceNo: number | null;
performedBy: string | null;
returnedBy: 'EDR' | 'CUSTOMER' | null;
statusHistory: Array<{
@@ -110,8 +115,11 @@ export interface EmptyContainerReturn {
}>;
}
export type EmptyContainerSize = '20' | '40';
export interface CreateEmptyContainerReturnPayload {
containerNumber: string;
containerSize?: EmptyContainerSize;
bookingId?: string;
customerId?: string;
returnDate?: string;
@@ -124,6 +132,16 @@ export interface CreateEmptyContainerReturnPayload {
returnedBy?: 'EDR' | 'CUSTOMER';
}
export interface LoadEmptyContainersOnTrainPayload extends ImportOperationActionPayload {
trainScheduleId: string;
trainNumber?: string;
items: Array<{
id: string;
containerSize: EmptyContainerSize;
wagonSequenceNo: number;
}>;
}
export interface UpdateEmptyContainerReturnStatusPayload extends ImportOperationActionPayload {
status: EmptyContainerReturnStatus;
wagonAllocationReference?: string;