Merge pull request #1029 from Tria-plc/edrmiles

Per-booking returns (existing) — click "Record Return" on booking row → select containers + warehouse

Standalone returns (new) — top of page button "Record Return" → single container, no booking required

Container number input
Warehouse dropdown
Return date, condition, handover notes
Submit
Commit: feat: add standalone container return modal
This commit is contained in:
Hagernesh Tadesse
2026-07-30 17:40:40 +03:00
committed by GitHub
2 changed files with 165 additions and 15 deletions

View File

@@ -162,25 +162,36 @@ describe('applyWagonOrderReversal', () => {
expect(applyWagonOrderReversal(plan, null)).toBe(plan);
});
it('flips the order and renumbers sequenceNo 1..N when the flag is true', () => {
it('flips the position numbers when the flag is true', () => {
const reversed = applyWagonOrderReversal(plan, true);
// Physically-last wagon (was seq 3, wt-c) is now position 1.
expect(reversed.map((s) => s.wagonTypeId)).toEqual(['wt-c', 'wt-b', 'wt-a']);
expect(reversed.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
// Physically-last wagon (wt-c) is now position 1.
expect(reversed.map((s) => s.sequenceNo)).toEqual([3, 2, 1]);
});
it('keeps each booking with its own wagon — only the position changes', () => {
const reversed = applyWagonOrderReversal(plan, true);
// The booking that was in the last wagon now sits at sequenceNo 1.
expect(reversed[0].sequenceNo).toBe(1);
const atPosition1 = reversed.find((s) => s.sequenceNo === 1);
expect(
(reversed[0].allocations as { bookingId: string }[])[0].bookingId,
(atPosition1?.allocations as { bookingId: string }[])[0].bookingId,
).toBe('BKG-C');
const atPosition3 = reversed.find((s) => s.sequenceNo === 3);
expect(
(reversed[2].allocations as { bookingId: string }[])[0].bookingId,
(atPosition3?.allocations as { bookingId: string }[])[0].bookingId,
).toBe('BKG-A');
});
// The regression that emptied every reversed train's container items: the
// placement generators pair unit k (booking order) with slot k of this array,
// and persistAllocationsAndLoads matches that sequenceNo against the
// allocation's booking. Array order must stay packing order.
it('keeps array order aligned with booking order so placements still match', () => {
const reversed = applyWagonOrderReversal(plan, true);
expect(
reversed.map((s) => (s.allocations as { bookingId: string }[])[0].bookingId),
).toEqual(['BKG-A', 'BKG-B', 'BKG-C']);
});
it('does not mutate the input plan', () => {
applyWagonOrderReversal(plan, true);
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);

View File

@@ -12,6 +12,7 @@ import {
Stack,
Table,
Text,
TextInput,
Textarea,
Select,
Checkbox,
@@ -57,6 +58,7 @@ export default function ContainerReturnsPage() {
const [expanded, setExpanded] = useState<string | null>(null);
const [filterType, setFilterType] = useState<ReturnType>("all");
const [returnModalOpen, setReturnModalOpen] = useState(false);
const [standaloneModalOpen, setStandaloneModalOpen] = useState(false);
const [activeKey, setActiveKey] = useState<string | null>(null);
const { data: unloadedQueue = [], isLoading: queueLoading } = useQuery({
@@ -76,7 +78,7 @@ export default function ContainerReturnsPage() {
for (const item of unloadedQueue) {
if (!item.bookingId) continue;
// EDR returns
// EDR last-mile returns: same EDR truck that delivered will return with empty containers
const edrTrucks = await warehouseService.getLastMileTrucks(item.bookingId).catch(() => []);
if (edrTrucks.length > 0) {
const inventory = await api.warehouses.listInventory
@@ -112,7 +114,7 @@ export default function ContainerReturnsPage() {
}
}
// Customer returns
// Customer self-haul last-mile returns: same customer truck that delivered will return with empty containers
const customerTrucks = await warehouseService.getCustomerTrucks(item.bookingId).catch(() => []);
if (customerTrucks.length > 0) {
const inventory = await api.warehouses.listInventory
@@ -229,19 +231,22 @@ export default function ContainerReturnsPage() {
<PageContainer>
<PageHeader
title="Container Returns"
subtitle="Track empty container returns by EDR or customer"
subtitle="Empty containers returned by last-mile trucks (EDR or customer self-haul)"
/>
<Group mb="lg">
<Group mb="lg" justify="space-between">
<SegmentedControl
value={filterType}
onChange={(val) => setFilterType(val as ReturnType)}
data={[
{ label: "All", value: "all" },
{ label: "EDR Returns", value: "edr" },
{ label: "Customer Returns", value: "customer" },
{ label: "EDR Last Mile", value: "edr" },
{ label: "Customer Self-Haul", value: "customer" },
]}
/>
<Button onClick={() => setStandaloneModalOpen(true)}>
Record Return
</Button>
</Group>
{filteredGroups.length === 0 ? (
@@ -282,7 +287,7 @@ export default function ContainerReturnsPage() {
<Table.Td>{group.companyName ?? "—"}</Table.Td>
<Table.Td>
<Badge color={group.returnType === "EDR" ? "edr-green" : "blue"}>
{group.returnType}
{group.returnType === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"}
</Badge>
</Table.Td>
<Table.Td>
@@ -348,6 +353,13 @@ export default function ContainerReturnsPage() {
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
loading={createReturnsMutation.isPending}
/>
<StandaloneReturnModal
opened={standaloneModalOpen}
onClose={() => setStandaloneModalOpen(false)}
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
loading={createReturnsMutation.isPending}
/>
</PageContainer>
);
}
@@ -416,7 +428,7 @@ function ContainerReturnModal({ opened, onClose, group, onSubmit, loading }: Con
<Group>
<Text fw={600}>{group.bookingRef}</Text>
<Badge color={group.returnType === "EDR" ? "edr-green" : "blue"}>
{group.returnType}
{group.returnType === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"}
</Badge>
</Group>
@@ -493,3 +505,130 @@ function ContainerReturnModal({ opened, onClose, group, onSubmit, loading }: Con
</Modal>
);
}
interface StandaloneReturnModalProps {
opened: boolean;
onClose: () => void;
onSubmit: (payload: any) => void;
loading: boolean;
}
function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: StandaloneReturnModalProps) {
const [containerNumber, setContainerNumber] = useState<string>("");
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
const [warehouse, setWarehouse] = useState<string | null>(null);
const [condition, setCondition] = useState<string>("");
const [handoverNote, setHandoverNote] = useState<string>("");
const { data: warehousesResponse } = useQuery({
queryKey: ["warehouses-list"],
queryFn: async () => {
return await warehouseService.list({});
},
});
const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? [];
const warehouseOptions = Array.isArray(warehouses)
? warehouses.map((wh: any) => ({
value: wh.id,
label: `${wh.name} ${wh.code ? `(${wh.code})` : ""}`,
}))
: [];
const handleSubmit = () => {
if (!containerNumber || !warehouse) return;
const selectedWarehouse = Array.isArray(warehouses) ? warehouses.find((wh: any) => wh.id === warehouse) : null;
onSubmit({
trucks: [
{
bookingId: null,
customerId: null,
returnType: "CUSTOMER",
containers: [
{
containerNumber,
returnDate,
warehouse: selectedWarehouse?.name || warehouse,
condition: condition || undefined,
handoverNote: handoverNote || undefined,
},
],
},
],
});
setContainerNumber("");
setReturnDate(new Date().toISOString().split("T")[0]);
setWarehouse(null);
setCondition("");
setHandoverNote("");
onClose();
};
return (
<Modal opened={opened} onClose={onClose} title="Record Container Return (Standalone)" size="lg">
<Stack gap="md">
<Text size="sm" c="dimmed">
Record container return without booking association
</Text>
<TextInput
label="Container Number"
placeholder="e.g., TEMU1234567"
value={containerNumber}
onChange={(e) => setContainerNumber(e.currentTarget.value)}
required
/>
<Select
label="Return Warehouse"
placeholder="Select warehouse for container return"
value={warehouse}
onChange={setWarehouse}
data={warehouseOptions}
required
searchable
/>
<input
type="date"
value={returnDate}
onChange={(e) => setReturnDate(e.target.value)}
style={{ padding: "8px", borderRadius: "4px", border: "1px solid #ccc" }}
required
/>
<Textarea
label="Condition"
placeholder="Damage, residue, or cleanliness notes"
value={condition}
onChange={(e) => setCondition(e.currentTarget.value)}
rows={3}
/>
<Textarea
label="Handover Note"
placeholder="Consignee, trucker, or authorization notes"
value={handoverNote}
onChange={(e) => setHandoverNote(e.currentTarget.value)}
rows={3}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!containerNumber || !warehouse}
loading={loading}
>
Record Return
</Button>
</Group>
</Stack>
</Modal>
);
}