mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: add Container Returns page with EDR/Customer filter
Separate page for tracking empty container returns by type: EDR returns (empty containers from EDR first-mile) or Customer returns (customer self-haul). Segmented control filter, expandable booking rows showing containers, modal to record return with warehouse selection. Accessible via sidebar menu under Import Operations. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -415,15 +415,22 @@ export function planWagonsWithStock(params: {
|
||||
* sequenceNos, the snapshot re-sorts by them, and the board/allocation views all
|
||||
* read them — so the stored train order and the schedule order stay identical,
|
||||
* just reversed. A false/absent flag returns the plan unchanged.
|
||||
*
|
||||
* Only the NUMBERS flip — the array itself stays in packing order. Container
|
||||
* placements are generated by walking the container units in booking order
|
||||
* against getContainerSlotSequenceNos(plan) in array order, then matched back to
|
||||
* their allocation by `sequenceNo:bookingId`. Reordering the array here broke
|
||||
* that pairing on every reversed schedule: unit 1 was handed the number of the
|
||||
* slot holding the LAST booking, the match missed, and persistAllocationsAndLoads
|
||||
* silently dropped every container item — which is why a reversed export train
|
||||
* printed a marshalling doc with no container numbers and 0/0 container counts.
|
||||
*/
|
||||
export function applyWagonOrderReversal(
|
||||
plan: WagonPlanSlot[],
|
||||
reverse: boolean | null | undefined,
|
||||
): WagonPlanSlot[] {
|
||||
if (!reverse) return plan;
|
||||
return [...plan]
|
||||
.reverse()
|
||||
.map((slot, index) => ({ ...slot, sequenceNo: index + 1 }));
|
||||
return plan.map((slot, index) => ({ ...slot, sequenceNo: plan.length - index }));
|
||||
}
|
||||
|
||||
/** Unbounded stock — used to compute pure demand for availability reporting. */
|
||||
|
||||
@@ -119,6 +119,7 @@ import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage"
|
||||
import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage";
|
||||
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||
import EDRLastMileReturnsPage from "./pages/warehouses/EDRLastMileReturnsPage";
|
||||
import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage";
|
||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
|
||||
import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage";
|
||||
@@ -418,6 +419,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <Container />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Container Returns",
|
||||
href: "/dashboard/container-returns",
|
||||
icon: <Container />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Dispatch Queue",
|
||||
href: "/dashboard/dispatch-queue",
|
||||
@@ -1068,6 +1075,7 @@ const App = () => {
|
||||
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
|
||||
<Route path="import-trucks" element={<ImportTrucksPage />} />
|
||||
<Route path="edr-last-mile-returns" element={<EDRLastMileReturnsPage />} />
|
||||
<Route path="container-returns" element={<ContainerReturnsPage />} />
|
||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||
<Route
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
Select,
|
||||
Checkbox,
|
||||
} from "@mantine/core";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
import { importOperationsService } from "@/services/importOperations.service";
|
||||
|
||||
type ReturnType = "all" | "edr" | "customer";
|
||||
|
||||
interface ContainerReturnRow {
|
||||
key: string;
|
||||
containerNumber: string;
|
||||
size: string | null;
|
||||
type: string | null;
|
||||
bookingRef: string;
|
||||
bookingId: string;
|
||||
customerId: string | null;
|
||||
companyName: string | null;
|
||||
returnType: "EDR" | "CUSTOMER";
|
||||
plate: string | null;
|
||||
isReturn: boolean;
|
||||
}
|
||||
|
||||
interface BookingReturnGroup {
|
||||
bookingId: string;
|
||||
bookingRef: string;
|
||||
companyName: string | null;
|
||||
customerId: string | null;
|
||||
returnType: "EDR" | "CUSTOMER";
|
||||
containers: ContainerReturnRow[];
|
||||
}
|
||||
|
||||
export default function ContainerReturnsPage() {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [filterType, setFilterType] = useState<ReturnType>("all");
|
||||
const [returnModalOpen, setReturnModalOpen] = useState(false);
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
|
||||
const { data: unloadedQueue = [], isLoading: queueLoading } = useQuery({
|
||||
queryKey: ["import-unloaded-queue"],
|
||||
queryFn: async () => {
|
||||
const response = await api.warehouses.importUnloadedQueue.call();
|
||||
return response ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
const bookingIds = unloadedQueue.map((item) => item.bookingId).filter(Boolean) as string[];
|
||||
const containerReturnsQuery = useQuery({
|
||||
queryKey: ["container-returns", bookingIds],
|
||||
queryFn: async () => {
|
||||
const groups = new Map<string, BookingReturnGroup>();
|
||||
|
||||
for (const item of unloadedQueue) {
|
||||
if (!item.bookingId) continue;
|
||||
|
||||
// EDR returns
|
||||
const edrTrucks = await warehouseService.getLastMileTrucks(item.bookingId).catch(() => []);
|
||||
if (edrTrucks.length > 0) {
|
||||
const inventory = await api.warehouses.listInventory
|
||||
.call({ filter: { bookingId: item.bookingId } })
|
||||
.catch(() => []);
|
||||
|
||||
const returnContainers: ContainerReturnRow[] = inventory
|
||||
.filter((inv: any) => inv.isReturn)
|
||||
.map((inv: any) => ({
|
||||
key: inv.id,
|
||||
containerNumber: inv.containerNumber || "—",
|
||||
size: inv.containerSize || null,
|
||||
type: inv.containerType || null,
|
||||
bookingRef: (item.bookingReference ?? item.bookingId) || "",
|
||||
bookingId: item.bookingId || "",
|
||||
customerId: item.customerId || null,
|
||||
companyName: item.customerName ?? null,
|
||||
returnType: "EDR" as const,
|
||||
plate: edrTrucks[0]?.truckPlateNumber || null,
|
||||
isReturn: true,
|
||||
}));
|
||||
|
||||
if (returnContainers.length > 0) {
|
||||
const key = `edr-${item.bookingId}`;
|
||||
groups.set(key, {
|
||||
bookingId: item.bookingId,
|
||||
bookingRef: item.bookingReference ?? item.bookingId,
|
||||
companyName: item.customerName ?? null,
|
||||
customerId: item.customerId || null,
|
||||
returnType: "EDR",
|
||||
containers: returnContainers,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Customer returns
|
||||
const customerTrucks = await warehouseService.getCustomerTrucks(item.bookingId).catch(() => []);
|
||||
if (customerTrucks.length > 0) {
|
||||
const inventory = await api.warehouses.listInventory
|
||||
.call({ filter: { bookingId: item.bookingId } })
|
||||
.catch(() => []);
|
||||
|
||||
const returnContainers: ContainerReturnRow[] = inventory
|
||||
.filter((inv: any) => inv.isReturn)
|
||||
.map((inv: any) => ({
|
||||
key: inv.id,
|
||||
containerNumber: inv.containerNumber || "—",
|
||||
size: inv.containerSize || null,
|
||||
type: inv.containerType || null,
|
||||
bookingRef: (item.bookingReference ?? item.bookingId) || "",
|
||||
bookingId: item.bookingId || "",
|
||||
customerId: item.customerId || null,
|
||||
companyName: item.customerName ?? null,
|
||||
returnType: "CUSTOMER" as const,
|
||||
plate: customerTrucks[0]?.plateNumber || null,
|
||||
isReturn: true,
|
||||
}));
|
||||
|
||||
if (returnContainers.length > 0) {
|
||||
const key = `customer-${item.bookingId}`;
|
||||
groups.set(key, {
|
||||
bookingId: item.bookingId,
|
||||
bookingRef: item.bookingReference ?? item.bookingId,
|
||||
companyName: item.customerName ?? null,
|
||||
customerId: item.customerId || null,
|
||||
returnType: "CUSTOMER",
|
||||
containers: returnContainers,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groups.values());
|
||||
},
|
||||
enabled: bookingIds.length > 0 && !queueLoading,
|
||||
});
|
||||
|
||||
const allGroups = useMemo(() => containerReturnsQuery.data ?? [], [containerReturnsQuery.data]);
|
||||
const filteredGroups = useMemo(() => {
|
||||
if (filterType === "all") return allGroups;
|
||||
if (filterType === "edr") return allGroups.filter((g) => g.returnType === "EDR");
|
||||
if (filterType === "customer") return allGroups.filter((g) => g.returnType === "CUSTOMER");
|
||||
return allGroups;
|
||||
}, [allGroups, filterType]);
|
||||
|
||||
const controls = useListControls(filteredGroups, {
|
||||
searchKeys: ["bookingRef", "companyName"],
|
||||
});
|
||||
|
||||
const createReturnsMutation = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
trucks: Array<{
|
||||
bookingId: string;
|
||||
customerId: string | null;
|
||||
returnType: "EDR" | "CUSTOMER";
|
||||
containers: Array<{
|
||||
containerNumber: string;
|
||||
returnDate: string;
|
||||
warehouse: string;
|
||||
condition?: string;
|
||||
handoverNote?: string;
|
||||
}>;
|
||||
}>;
|
||||
}) => {
|
||||
const results = [];
|
||||
for (const truck of payload.trucks) {
|
||||
for (const container of truck.containers) {
|
||||
const result = await importOperationsService.createEmptyReturn({
|
||||
containerNumber: container.containerNumber,
|
||||
returnDate: new Date(container.returnDate).toISOString(),
|
||||
bookingId: truck.bookingId,
|
||||
customerId: truck.customerId ?? undefined,
|
||||
facility: container.warehouse,
|
||||
condition: container.condition,
|
||||
handoverNote: container.handoverNote,
|
||||
});
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Container returns recorded" });
|
||||
qc.invalidateQueries({ queryKey: ["container-returns", bookingIds] });
|
||||
setReturnModalOpen(false);
|
||||
setActiveKey(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to record returns",
|
||||
description: error?.response?.data?.message || error?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const activeGroup = activeKey ? (filteredGroups.find((g) => `${g.returnType.toLowerCase()}-${g.bookingId}` === activeKey) ?? null) : null;
|
||||
|
||||
if (queueLoading || containerReturnsQuery.isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Container Returns"
|
||||
subtitle="Track empty container returns by EDR or customer"
|
||||
/>
|
||||
|
||||
<Group mb="lg">
|
||||
<SegmentedControl
|
||||
value={filterType}
|
||||
onChange={(val) => setFilterType(val as ReturnType)}
|
||||
data={[
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "EDR Returns", value: "edr" },
|
||||
{ label: "Customer Returns", value: "customer" },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{filteredGroups.length === 0 ? (
|
||||
<Alert color="gray">No {filterType !== "all" ? filterType : ""} container returns found.</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={1000}>
|
||||
<Table highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40} />
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>Company</Table.Th>
|
||||
<Table.Th>Return Type</Table.Th>
|
||||
<Table.Th>Containers</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{controls.pagedRows.map((group) => {
|
||||
const groupKey = `${group.returnType.toLowerCase()}-${group.bookingId}`;
|
||||
const isOpen = expanded === groupKey;
|
||||
return (
|
||||
<Fragment key={groupKey}>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setExpanded(isOpen ? null : groupKey)}
|
||||
>
|
||||
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={600}>{group.bookingRef}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{group.companyName ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={group.returnType === "EDR" ? "edr-green" : "blue"}>
|
||||
{group.returnType}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge>{group.containers.length} container{group.containers.length !== 1 ? "s" : ""}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => {
|
||||
setActiveKey(groupKey);
|
||||
setReturnModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Record Return
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{isOpen && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th>Size</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{group.containers.map((container) => (
|
||||
<Table.Tr key={container.key}>
|
||||
<Table.Td>{container.containerNumber}</Table.Td>
|
||||
<Table.Td>{container.size ?? "—"}</Table.Td>
|
||||
<Table.Td>{container.type ?? "—"}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="bookings"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ContainerReturnModal
|
||||
opened={returnModalOpen}
|
||||
onClose={() => setReturnModalOpen(false)}
|
||||
group={activeGroup}
|
||||
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
|
||||
loading={createReturnsMutation.isPending}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface ContainerReturnModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
group: BookingReturnGroup | null;
|
||||
onSubmit: (payload: any) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function ContainerReturnModal({ opened, onClose, group, onSubmit, loading }: ContainerReturnModalProps) {
|
||||
const [selectedContainers, setSelectedContainers] = 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 (!group || !selectedContainers.length || !warehouse) return;
|
||||
|
||||
const selectedWarehouse = Array.isArray(warehouses) ? warehouses.find((wh: any) => wh.id === warehouse) : null;
|
||||
|
||||
const containers = group.containers
|
||||
.filter((c) => selectedContainers.includes(c.key))
|
||||
.map((c) => ({
|
||||
containerNumber: c.containerNumber,
|
||||
returnDate,
|
||||
warehouse: selectedWarehouse?.name || warehouse,
|
||||
condition: condition || undefined,
|
||||
handoverNote: handoverNote || undefined,
|
||||
}));
|
||||
|
||||
onSubmit({
|
||||
trucks: [
|
||||
{
|
||||
bookingId: group.bookingId,
|
||||
customerId: group.customerId,
|
||||
returnType: group.returnType,
|
||||
containers,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Record Container Return" size="lg">
|
||||
{group && (
|
||||
<Stack gap="md">
|
||||
<Group>
|
||||
<Text fw={600}>{group.bookingRef}</Text>
|
||||
<Badge color={group.returnType === "EDR" ? "edr-green" : "blue"}>
|
||||
{group.returnType}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
Select containers to return:
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
{group.containers.map((container) => (
|
||||
<Checkbox
|
||||
key={container.key}
|
||||
label={`${container.containerNumber} (${container.size || "bulk"})`}
|
||||
checked={selectedContainers.includes(container.key)}
|
||||
onChange={(e) => {
|
||||
if (e.currentTarget.checked) {
|
||||
setSelectedContainers([...selectedContainers, container.key]);
|
||||
} else {
|
||||
setSelectedContainers(selectedContainers.filter((c) => c !== container.key));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<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={!selectedContainers.length || !warehouse}
|
||||
loading={loading}
|
||||
>
|
||||
Record Return ({selectedContainers.length})
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user