mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 20:40:55 +00:00
feat(train-scheduling): load returned empties onto an export train
Empties had no way onto a departure: the return record could name a train but nothing seated it on a wagon. Export schedules now expose a loading action that packs selected returns onto free wagons at one 40ft or two 20ft each, enforced both in the picker and in the API (existing empties on the schedule count against their wagon). Adds container_size, train_schedule_id and wagon_sequence_no to freight.empty_container_returns.
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;
|
||||
Reference in New Issue
Block a user