mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1292 from Tria-plc/dev
Empty Container wagon assignment and return handled
This commit is contained in:
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -513,6 +513,17 @@ export class BillingService {
|
||||
// MoR EIMS reference — only once actually registered, never a placeholder row.
|
||||
if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn });
|
||||
|
||||
// PNR — the CBE_BILL reference the customer pays against, written onto the booking at
|
||||
// payment-initiation time (see initiatePayment()). Not a column on Invoice/Payment, so
|
||||
// look it up by source id; only shown once a payment actually generated one.
|
||||
if (invoice.source === Freight.InvoiceSource.Booking) {
|
||||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: invoice.sourceId },
|
||||
select: ["id", "pnrCode"],
|
||||
});
|
||||
if (booking?.pnrCode) summary.push({ label: "PNR", value: booking.pnrCode });
|
||||
}
|
||||
|
||||
return {
|
||||
kind,
|
||||
title,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1132,6 +1132,36 @@ describe('TrainSchedulingService', () => {
|
||||
expect(html).toContain('2 (1 empty)');
|
||||
});
|
||||
|
||||
it('lists loaded empty containers by number and states they are empty', () => {
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
trainNumber: '8301',
|
||||
direction: 'EXPORT',
|
||||
trainSet: {
|
||||
wagons: [makeWagon(1, 'W-001', []), makeWagon(2, 'W-002', []), makeWagon(3, 'W-003', [])],
|
||||
},
|
||||
scheduleBookings: [],
|
||||
};
|
||||
|
||||
const html = (service as never as {
|
||||
buildExportLoadListHtml: (s: unknown, o?: unknown) => string;
|
||||
}).buildExportLoadListHtml(schedule, {
|
||||
emptyContainers: [
|
||||
{ containerNumber: 'CMU9876543', containerSize: '40', wagonSequenceNo: 1 },
|
||||
{ containerNumber: 'TEMU1112223', containerSize: '20', wagonSequenceNo: 2 },
|
||||
{ containerNumber: 'TEMU4445556', containerSize: '20', wagonSequenceNo: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(html).toContain('CMU9876543');
|
||||
expect(html).toContain('TEMU1112223, TEMU4445556');
|
||||
expect(html.match(/EMPTY CONTAINER/g)).toHaveLength(2);
|
||||
// Wagon 3 carries nothing at all, so it keeps the bare-wagon wording.
|
||||
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1);
|
||||
expect(html).toContain('3 (1 empty)');
|
||||
expect(html).toContain('<span>Empty containers</span><strong>3</strong>');
|
||||
});
|
||||
|
||||
it('renders wagons in consist order regardless of the order the relation returns', () => {
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
ImportDjiboutiOperation,
|
||||
type ImportDjiboutiDocumentType,
|
||||
} from '../entities/import-djibouti-operation.entity';
|
||||
import { EmptyContainerReturn } from '../../import-operations/entities/empty-container-return.entity';
|
||||
import {
|
||||
ImportDjiboutiActionDto,
|
||||
UploadImportDjiboutiDocumentDto,
|
||||
@@ -3106,6 +3107,7 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
const html = this.buildExportLoadListHtml(schedule, {
|
||||
emptyContainers: await this.loadedEmptyContainers(scheduleId),
|
||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||
});
|
||||
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
||||
@@ -3179,6 +3181,7 @@ export class TrainSchedulingService {
|
||||
positionLabel,
|
||||
wagons,
|
||||
unassignedBookings,
|
||||
emptyContainers: await this.loadedEmptyContainers(scheduleId),
|
||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||
});
|
||||
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
||||
@@ -3216,6 +3219,18 @@ export class TrainSchedulingService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty containers riding this departure back to Djibouti. They carry no
|
||||
* booking and no wagon allocation, so the marshalling document would show
|
||||
* their wagons as bare — staff checking the paper against the train would
|
||||
* find boxes that the list denies are there.
|
||||
*/
|
||||
private loadedEmptyContainers(scheduleId: string): Promise<EmptyContainerReturn[]> {
|
||||
return this.dataSource
|
||||
.getRepository(EmptyContainerReturn)
|
||||
.find({ where: { trainScheduleId: scheduleId } });
|
||||
}
|
||||
|
||||
private buildExportLoadListHtml(
|
||||
schedule: TrainSchedule,
|
||||
opts?: {
|
||||
@@ -3223,6 +3238,7 @@ export class TrainSchedulingService {
|
||||
positionLabel?: string;
|
||||
wagons?: TrainSetWagon[];
|
||||
unassignedBookings?: Booking[];
|
||||
emptyContainers?: EmptyContainerReturn[];
|
||||
logoImageUrl?: string | null;
|
||||
},
|
||||
): string {
|
||||
@@ -3241,6 +3257,16 @@ export class TrainSchedulingService {
|
||||
const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])].sort(
|
||||
(a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0),
|
||||
);
|
||||
// Empties sit on wagons that carry no booking allocation, keyed by the wagon
|
||||
// slot recorded when they were loaded.
|
||||
const emptiesByWagon = new Map<number, EmptyContainerReturn[]>();
|
||||
for (const empty of opts?.emptyContainers ?? []) {
|
||||
if (empty.wagonSequenceNo == null) continue;
|
||||
emptiesByWagon.set(empty.wagonSequenceNo, [
|
||||
...(emptiesByWagon.get(empty.wagonSequenceNo) ?? []),
|
||||
empty,
|
||||
]);
|
||||
}
|
||||
const rows = wagons
|
||||
.flatMap((wagon) => {
|
||||
// Wagon identity is the same on every row the wagon produces, loaded or not.
|
||||
@@ -3255,6 +3281,21 @@ export class TrainSchedulingService {
|
||||
// check this document against the physical train — a wagon with no row
|
||||
// reads as a wagon that is not there, and the count stops matching.
|
||||
if (allocations.length === 0) {
|
||||
const empties = emptiesByWagon.get(Number(wagon.sequenceNo)) ?? [];
|
||||
// Empty boxes returning to Djibouti: numbers listed like any other
|
||||
// container, state spelled out so nobody reads them as laden.
|
||||
if (empties.length) {
|
||||
return [
|
||||
`<tr>
|
||||
${wagonCells}
|
||||
<td>EMPTY CONTAINER</td>
|
||||
<td>-</td>
|
||||
<td>${esc(empties.map((empty) => empty.containerNumber).filter(Boolean).join(', '))}</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
</tr>`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
`<tr class="empty">
|
||||
${wagonCells}
|
||||
@@ -3305,14 +3346,19 @@ export class TrainSchedulingService {
|
||||
})
|
||||
.join('')
|
||||
: '';
|
||||
const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length;
|
||||
const emptyWagons = wagons.filter(
|
||||
(wagon) =>
|
||||
(wagon.allocations ?? []).length === 0 &&
|
||||
!emptiesByWagon.get(Number(wagon.sequenceNo))?.length,
|
||||
).length;
|
||||
const totalWeight = wagons.reduce(
|
||||
(sum, wagon) =>
|
||||
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||
0,
|
||||
);
|
||||
|
||||
// Container count summary (40ft, 20ft)
|
||||
// Container count summary (40ft, 20ft) — empties returning to Djibouti are
|
||||
// physically on the train, so they count, and are called out on their own tile.
|
||||
let count40ft = 0, count20ft = 0;
|
||||
wagons.forEach((wagon) => {
|
||||
(wagon.allocations ?? []).forEach((allocation) => {
|
||||
@@ -3323,6 +3369,11 @@ export class TrainSchedulingService {
|
||||
});
|
||||
});
|
||||
});
|
||||
const emptyContainers = [...emptiesByWagon.values()].flat();
|
||||
for (const empty of emptyContainers) {
|
||||
if (empty.containerSize?.includes('20')) count20ft++;
|
||||
else count40ft++;
|
||||
}
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
@@ -3378,6 +3429,7 @@ export class TrainSchedulingService {
|
||||
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
|
||||
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
|
||||
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
|
||||
${emptyContainers.length ? `<div class="tile"><span>Empty containers</span><strong>${esc(emptyContainers.length)}</strong></div>` : ''}
|
||||
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
|
||||
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -173,7 +173,10 @@ export default function CustomerDetailPage() {
|
||||
enabled: Boolean(id),
|
||||
}),
|
||||
);
|
||||
const contractsQuery = useContractList({ companyId: id, pageSize: 100 }, Boolean(id));
|
||||
const contractsQuery = useContractList(
|
||||
{ companyId: id, pageSize: 100 },
|
||||
Boolean(id),
|
||||
);
|
||||
|
||||
const { pagination: invoicePagination, setPagination: setInvoicePagination } =
|
||||
usePagination({
|
||||
@@ -462,42 +465,31 @@ export default function CustomerDetailPage() {
|
||||
|
||||
const documentColumns: ColumnDef<CustomerDocument>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
header: "Document",
|
||||
cell: ({ row }) => {
|
||||
const doc = row.original;
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<FileText size={16} className="shrink-0 text-edr-muted" />
|
||||
<Text size="sm" c="edr-text" truncate>
|
||||
{doc.name}
|
||||
</Text>
|
||||
{doc.reviewStatus === "change_requested" && (
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
Change requested
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{/* The note is the whole point of the request — show it inline so a
|
||||
second reviewer sees what was already asked for. */}
|
||||
{doc.reviewStatus === "change_requested" && doc.reviewNote && (
|
||||
<Text size="xs" c="dimmed" pl={24}>
|
||||
{doc.reviewNote}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "code",
|
||||
header: "Type",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{humanize(row.original.code)}
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<FileText size={16} className="shrink-0 text-edr-muted" />
|
||||
<Text size="sm" c="edr-text" truncate>
|
||||
{humanize(row.original.code)}
|
||||
</Text>
|
||||
{row.original.reviewStatus === "change_requested" && (
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
Change requested
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{/* The note is the whole point of the request — show it inline so a
|
||||
second reviewer sees what was already asked for. */}
|
||||
{row.original.reviewStatus === "change_requested" &&
|
||||
row.original.reviewNote && (
|
||||
<Text size="xs" c="dimmed" pl={24}>
|
||||
{row.original.reviewNote}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -751,9 +743,8 @@ export default function CustomerDetailPage() {
|
||||
]}
|
||||
backTo="/dashboard/customers"
|
||||
title={company.name}
|
||||
subtitle={`TIN ${company.tin}${
|
||||
company.country ? ` · ${company.country}` : ""
|
||||
}`}
|
||||
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
|
||||
}`}
|
||||
meta={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<CompanyTypeBadge type={company.type} />
|
||||
@@ -1247,13 +1238,15 @@ export default function CustomerDetailPage() {
|
||||
status={tableStatus(bookingsQuery)}
|
||||
emptyMessage="No bookings for this customer."
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
onRowClick={(row) => navigate(`/dashboard/booking-requests/${row.id}`)}
|
||||
onRowClick={(row) =>
|
||||
navigate(`/dashboard/booking-requests/${row.id}`)
|
||||
}
|
||||
error={
|
||||
bookingsQuery.isError
|
||||
? {
|
||||
message: "Failed to load bookings.",
|
||||
onRetry: () => void bookingsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load bookings.",
|
||||
onRetry: () => void bookingsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -1269,13 +1262,15 @@ export default function CustomerDetailPage() {
|
||||
status={tableStatus(contractsQuery)}
|
||||
emptyMessage="No contracts for this customer."
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
onRowClick={(row) => navigate(`/dashboard/contract-requests/${row.id}`)}
|
||||
onRowClick={(row) =>
|
||||
navigate(`/dashboard/contract-requests/${row.id}`)
|
||||
}
|
||||
error={
|
||||
contractsQuery.isError
|
||||
? {
|
||||
message: "Failed to load contracts.",
|
||||
onRetry: () => void contractsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load contracts.",
|
||||
onRetry: () => void contractsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -1295,9 +1290,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
documentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -1372,9 +1367,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
paymentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load payments.",
|
||||
onRetry: () => void paymentsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load payments.",
|
||||
onRetry: () => void paymentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -1395,9 +1390,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
invoicesQuery.isError
|
||||
? {
|
||||
message: "Failed to load invoices.",
|
||||
onRetry: () => void invoicesQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load invoices.",
|
||||
onRetry: () => void invoicesQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -36,7 +36,9 @@ 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";
|
||||
|
||||
type ReturnType = "all" | "edr" | "customer";
|
||||
@@ -100,6 +102,7 @@ export default function ContainerReturnsPage() {
|
||||
const [standaloneModalOpen, setStandaloneModalOpen] = useState(false);
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
const [historyRow, setHistoryRow] = useState<any | null>(null);
|
||||
const [allocateRow, setAllocateRow] = useState<EmptyContainerReturn | null>(null);
|
||||
|
||||
const { data: unloadedQueue = [], isLoading: queueLoading } = useQuery({
|
||||
queryKey: ["import-unloaded-queue"],
|
||||
@@ -265,6 +268,7 @@ export default function ContainerReturnsPage() {
|
||||
returnType: "EDR" | "CUSTOMER";
|
||||
containers: Array<{
|
||||
containerNumber: string;
|
||||
containerSize?: EmptyContainerSize;
|
||||
returnDate: string;
|
||||
warehouse: string;
|
||||
condition?: string;
|
||||
@@ -277,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,
|
||||
@@ -306,15 +311,25 @@ export default function ContainerReturnsPage() {
|
||||
});
|
||||
|
||||
const advanceStatusMutation = useMutation({
|
||||
mutationFn: (id: string) => {
|
||||
mutationFn: ({
|
||||
id,
|
||||
wagonAllocationReference,
|
||||
}: {
|
||||
id: string;
|
||||
wagonAllocationReference?: string;
|
||||
}) => {
|
||||
const current = returnedContainers.find((r: any) => r.id === id);
|
||||
const nextIndex = RETURN_STATUS_ORDER.indexOf(current?.status ?? "RETURNED") + 1;
|
||||
const status = RETURN_STATUS_ORDER[nextIndex] ?? "COMPLETED";
|
||||
return importOperationsService.updateEmptyReturnStatus(id, { status });
|
||||
return importOperationsService.updateEmptyReturnStatus(id, {
|
||||
status,
|
||||
wagonAllocationReference,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Return status updated" });
|
||||
qc.invalidateQueries({ queryKey: ["empty-container-returns"] });
|
||||
setAllocateRow(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
@@ -381,9 +396,16 @@ export default function ContainerReturnsPage() {
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm">
|
||||
{RETURN_STATUS_LABEL[row.original.status] ?? row.original.status}
|
||||
</Badge>
|
||||
<Stack gap={2}>
|
||||
<Badge size="sm">
|
||||
{RETURN_STATUS_LABEL[row.original.status] ?? row.original.status}
|
||||
</Badge>
|
||||
{row.original.wagonAllocationReference && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Train {row.original.wagonAllocationReference}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -406,8 +428,15 @@ export default function ContainerReturnsPage() {
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
loading={advanceStatusMutation.isPending && advanceStatusMutation.variables === ret.id}
|
||||
onClick={() => advanceStatusMutation.mutate(ret.id)}
|
||||
loading={
|
||||
advanceStatusMutation.isPending &&
|
||||
advanceStatusMutation.variables?.id === ret.id
|
||||
}
|
||||
onClick={() =>
|
||||
nextStatus === "WAGON_ALLOCATED"
|
||||
? setAllocateRow(ret)
|
||||
: advanceStatusMutation.mutate({ id: ret.id })
|
||||
}
|
||||
>
|
||||
Advance to {RETURN_STATUS_LABEL[nextStatus]}
|
||||
</Button>
|
||||
@@ -629,6 +658,19 @@ export default function ContainerReturnsPage() {
|
||||
loading={createReturnsMutation.isPending}
|
||||
/>
|
||||
|
||||
<ExportTrainAllocationModal
|
||||
row={allocateRow}
|
||||
onClose={() => setAllocateRow(null)}
|
||||
onSubmit={(reference) =>
|
||||
allocateRow &&
|
||||
advanceStatusMutation.mutate({
|
||||
id: allocateRow.id,
|
||||
wagonAllocationReference: reference,
|
||||
})
|
||||
}
|
||||
loading={advanceStatusMutation.isPending}
|
||||
/>
|
||||
|
||||
<Modal opened={!!historyRow} onClose={() => setHistoryRow(null)} title="Status History" size="sm">
|
||||
{historyRow && (
|
||||
<Stack gap="sm">
|
||||
@@ -649,6 +691,123 @@ export default function ContainerReturnsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Empties ride an EXPORT departure back to Djibouti, so wagon allocation picks
|
||||
* from the export schedules that have not left yet (DRAFT/SCHEDULED). The pick
|
||||
* is recorded as the return's `wagonAllocationReference`.
|
||||
*/
|
||||
function ExportTrainAllocationModal({
|
||||
row,
|
||||
onClose,
|
||||
onSubmit,
|
||||
loading,
|
||||
}: {
|
||||
row: EmptyContainerReturn | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (reference: string) => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const [scheduleId, setScheduleId] = useState<string | null>(null);
|
||||
|
||||
const schedulesQuery = useQuery(
|
||||
api.trainScheduling.scheduleList.queryOptions({
|
||||
input: { filters: { pageSize: 100, sortBy: "scheduledDepartureDate", sortOrder: "ASC" } },
|
||||
enabled: Boolean(row),
|
||||
}),
|
||||
);
|
||||
|
||||
const exportTrains = useMemo(
|
||||
() =>
|
||||
((schedulesQuery.data?.items ?? []) as TrainScheduleListItem[]).filter(
|
||||
(s) => s.direction === "EXPORT" && (s.status === "DRAFT" || s.status === "SCHEDULED"),
|
||||
),
|
||||
[schedulesQuery.data],
|
||||
);
|
||||
|
||||
const referenceOf = (train: TrainScheduleListItem) =>
|
||||
train.trainNumber || train.reference || train.id;
|
||||
|
||||
return (
|
||||
<Modal opened={!!row} onClose={onClose} title="Allocate to Export Train" size="lg">
|
||||
{row && (
|
||||
<Stack gap="md">
|
||||
<Text fw={600}>{row.containerNumber}</Text>
|
||||
|
||||
{schedulesQuery.isLoading ? (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : exportTrains.length === 0 ? (
|
||||
<Alert color="gray">No export train is scheduled — create one in Train Scheduling.</Alert>
|
||||
) : (
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40} />
|
||||
<Table.Th>Train</Table.Th>
|
||||
<Table.Th>Departure</Table.Th>
|
||||
<Table.Th>Route</Table.Th>
|
||||
<Table.Th>Wagons</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{exportTrains.map((train) => (
|
||||
<Table.Tr
|
||||
key={train.id}
|
||||
onClick={() => setScheduleId(train.id)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
checked={scheduleId === train.id}
|
||||
onChange={() => setScheduleId(train.id)}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{referenceOf(train)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{train.scheduleDate ? new Date(train.scheduleDate).toLocaleDateString() : "—"}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{train.origin ?? "—"} → {train.destination ?? "—"}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{train.wagonsUsed ?? 0}/{train.wagonCount}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm">{train.status}</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!scheduleId}
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
const train = exportTrains.find((t) => t.id === scheduleId);
|
||||
if (train) onSubmit(referenceOf(train));
|
||||
}}
|
||||
>
|
||||
Allocate
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface ContainerReturnModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
@@ -688,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -336,6 +336,7 @@ const App = () => {
|
||||
createProfile,
|
||||
reapplyProfile,
|
||||
isAuthenticated,
|
||||
isShippingLine,
|
||||
} = useAuth();
|
||||
|
||||
// Attribute replays and exceptions to the signed-in user (id/org only).
|
||||
@@ -410,66 +411,72 @@ const App = () => {
|
||||
at their own routes — nothing here is shared with the customer
|
||||
branch below beyond the shell component itself. Contracts are
|
||||
absent by design: shipping lines request bookings directly. */}
|
||||
<Route element={<RequireShippingLine />}>
|
||||
<Route
|
||||
element={
|
||||
<AppLayout
|
||||
title="EDR Freight"
|
||||
sidebarItems={shippingLineSidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
// Support chat is company-scoped; a shipping line has no
|
||||
// company, so every poll would 403.
|
||||
showSupportWidget={false}
|
||||
>
|
||||
<Outlet />
|
||||
</AppLayout>
|
||||
}
|
||||
>
|
||||
{isShippingLine && (
|
||||
<Route element={<RequireShippingLine />}>
|
||||
<Route
|
||||
path="/shipping-line"
|
||||
element={<ShippingLineHomePage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/bookings"
|
||||
element={<ShippingLineBookingsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/bookings/:id"
|
||||
element={<ShippingLineBookingDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/bookings/:id/complete"
|
||||
element={<ShippingLineCompletePage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/invoices"
|
||||
element={<ShippingLineInvoicesPage />}
|
||||
/>
|
||||
{/* Same detail component as the customer's /billing/:id — the
|
||||
element={
|
||||
<AppLayout
|
||||
title="EDR Freight"
|
||||
sidebarItems={shippingLineSidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
// Support chat is company-scoped; a shipping line has no
|
||||
// company, so every poll would 403.
|
||||
showSupportWidget={false}
|
||||
>
|
||||
<Outlet />
|
||||
</AppLayout>
|
||||
}
|
||||
>
|
||||
<Route
|
||||
path="/shipping-line"
|
||||
element={<ShippingLineHomePage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/bookings"
|
||||
element={<ShippingLineBookingsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/bookings/:id"
|
||||
element={<ShippingLineBookingDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/bookings/:id/complete"
|
||||
element={<ShippingLineCompletePage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/invoices"
|
||||
element={<ShippingLineInvoicesPage />}
|
||||
/>
|
||||
{/* Same detail component as the customer's /billing/:id — the
|
||||
API scopes my-invoices to the signed-in payer either way,
|
||||
and the page derives its back target from the URL. */}
|
||||
<Route
|
||||
path="/shipping-line/invoices/:id"
|
||||
element={<InvoiceDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/settings"
|
||||
element={<ShippingLineSettingsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/help"
|
||||
element={<ShippingLineHelpPage />}
|
||||
/>
|
||||
{/* Old shared links land on the shipping-line equivalents. */}
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<Navigate to="/shipping-line/settings" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/invoices/:id"
|
||||
element={<InvoiceDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/settings"
|
||||
element={<ShippingLineSettingsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/settings"
|
||||
element={<ShippingLineSettingsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/help"
|
||||
element={<ShippingLineHelpPage />}
|
||||
/>
|
||||
{/* Old shared links land on the shipping-line equivalents. */}
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<Navigate to="/shipping-line/settings" replace />}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
)}
|
||||
|
||||
{/* Customer app — unchanged. */}
|
||||
<Route element={<RequireCustomer />}>
|
||||
|
||||
Reference in New Issue
Block a user