mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user