mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
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:
@@ -162,25 +162,36 @@ describe('applyWagonOrderReversal', () => {
|
|||||||
expect(applyWagonOrderReversal(plan, null)).toBe(plan);
|
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);
|
const reversed = applyWagonOrderReversal(plan, true);
|
||||||
// Physically-last wagon (was seq 3, wt-c) is now position 1.
|
// Physically-last wagon (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([3, 2, 1]);
|
||||||
expect(reversed.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps each booking with its own wagon — only the position changes', () => {
|
it('keeps each booking with its own wagon — only the position changes', () => {
|
||||||
const reversed = applyWagonOrderReversal(plan, true);
|
const reversed = applyWagonOrderReversal(plan, true);
|
||||||
// The booking that was in the last wagon now sits at sequenceNo 1.
|
// 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(
|
expect(
|
||||||
(reversed[0].allocations as { bookingId: string }[])[0].bookingId,
|
(atPosition1?.allocations as { bookingId: string }[])[0].bookingId,
|
||||||
).toBe('BKG-C');
|
).toBe('BKG-C');
|
||||||
|
const atPosition3 = reversed.find((s) => s.sequenceNo === 3);
|
||||||
expect(
|
expect(
|
||||||
(reversed[2].allocations as { bookingId: string }[])[0].bookingId,
|
(atPosition3?.allocations as { bookingId: string }[])[0].bookingId,
|
||||||
).toBe('BKG-A');
|
).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', () => {
|
it('does not mutate the input plan', () => {
|
||||||
applyWagonOrderReversal(plan, true);
|
applyWagonOrderReversal(plan, true);
|
||||||
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
|
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
Textarea,
|
Textarea,
|
||||||
Select,
|
Select,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
@@ -57,6 +58,7 @@ export default function ContainerReturnsPage() {
|
|||||||
const [expanded, setExpanded] = useState<string | null>(null);
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
const [filterType, setFilterType] = useState<ReturnType>("all");
|
const [filterType, setFilterType] = useState<ReturnType>("all");
|
||||||
const [returnModalOpen, setReturnModalOpen] = useState(false);
|
const [returnModalOpen, setReturnModalOpen] = useState(false);
|
||||||
|
const [standaloneModalOpen, setStandaloneModalOpen] = useState(false);
|
||||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: unloadedQueue = [], isLoading: queueLoading } = useQuery({
|
const { data: unloadedQueue = [], isLoading: queueLoading } = useQuery({
|
||||||
@@ -76,7 +78,7 @@ export default function ContainerReturnsPage() {
|
|||||||
for (const item of unloadedQueue) {
|
for (const item of unloadedQueue) {
|
||||||
if (!item.bookingId) continue;
|
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(() => []);
|
const edrTrucks = await warehouseService.getLastMileTrucks(item.bookingId).catch(() => []);
|
||||||
if (edrTrucks.length > 0) {
|
if (edrTrucks.length > 0) {
|
||||||
const inventory = await api.warehouses.listInventory
|
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(() => []);
|
const customerTrucks = await warehouseService.getCustomerTrucks(item.bookingId).catch(() => []);
|
||||||
if (customerTrucks.length > 0) {
|
if (customerTrucks.length > 0) {
|
||||||
const inventory = await api.warehouses.listInventory
|
const inventory = await api.warehouses.listInventory
|
||||||
@@ -229,19 +231,22 @@ export default function ContainerReturnsPage() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Container Returns"
|
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
|
<SegmentedControl
|
||||||
value={filterType}
|
value={filterType}
|
||||||
onChange={(val) => setFilterType(val as ReturnType)}
|
onChange={(val) => setFilterType(val as ReturnType)}
|
||||||
data={[
|
data={[
|
||||||
{ label: "All", value: "all" },
|
{ label: "All", value: "all" },
|
||||||
{ label: "EDR Returns", value: "edr" },
|
{ label: "EDR Last Mile", value: "edr" },
|
||||||
{ label: "Customer Returns", value: "customer" },
|
{ label: "Customer Self-Haul", value: "customer" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
<Button onClick={() => setStandaloneModalOpen(true)}>
|
||||||
|
Record Return
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{filteredGroups.length === 0 ? (
|
{filteredGroups.length === 0 ? (
|
||||||
@@ -282,7 +287,7 @@ export default function ContainerReturnsPage() {
|
|||||||
<Table.Td>{group.companyName ?? "—"}</Table.Td>
|
<Table.Td>{group.companyName ?? "—"}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge color={group.returnType === "EDR" ? "edr-green" : "blue"}>
|
<Badge color={group.returnType === "EDR" ? "edr-green" : "blue"}>
|
||||||
{group.returnType}
|
{group.returnType === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
@@ -348,6 +353,13 @@ export default function ContainerReturnsPage() {
|
|||||||
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
|
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
|
||||||
loading={createReturnsMutation.isPending}
|
loading={createReturnsMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<StandaloneReturnModal
|
||||||
|
opened={standaloneModalOpen}
|
||||||
|
onClose={() => setStandaloneModalOpen(false)}
|
||||||
|
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
|
||||||
|
loading={createReturnsMutation.isPending}
|
||||||
|
/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -416,7 +428,7 @@ function ContainerReturnModal({ opened, onClose, group, onSubmit, loading }: Con
|
|||||||
<Group>
|
<Group>
|
||||||
<Text fw={600}>{group.bookingRef}</Text>
|
<Text fw={600}>{group.bookingRef}</Text>
|
||||||
<Badge color={group.returnType === "EDR" ? "edr-green" : "blue"}>
|
<Badge color={group.returnType === "EDR" ? "edr-green" : "blue"}>
|
||||||
{group.returnType}
|
{group.returnType === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -493,3 +505,130 @@ function ContainerReturnModal({ opened, onClose, group, onSubmit, loading }: Con
|
|||||||
</Modal>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user