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:
Hagernesh
2026-07-30 13:58:30 +00:00
parent 154b7d11d5
commit c0d6b1a1c5
3 changed files with 513 additions and 3 deletions

View File

@@ -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. */

View File

@@ -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

View File

@@ -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>
);
}