diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index 699d7a432..bfc3e3be8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -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. */ diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 2df7d462b..d523d2e8a 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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: , permission: FREIGHT_PERMS.warehouseInventory.view, }, + { + label: "Container Returns", + href: "/dashboard/container-returns", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, { label: "Dispatch Queue", href: "/dashboard/dispatch-queue", @@ -1068,6 +1075,7 @@ const App = () => { } /> } /> } /> + } /> } /> } /> (null); + const [filterType, setFilterType] = useState("all"); + const [returnModalOpen, setReturnModalOpen] = useState(false); + const [activeKey, setActiveKey] = useState(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(); + + 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 ( + + + + + + ); + } + + return ( + + + + + setFilterType(val as ReturnType)} + data={[ + { label: "All", value: "all" }, + { label: "EDR Returns", value: "edr" }, + { label: "Customer Returns", value: "customer" }, + ]} + /> + + + {filteredGroups.length === 0 ? ( + No {filterType !== "all" ? filterType : ""} container returns found. + ) : ( + <> + + + + + + Booking Ref + Company + Return Type + Containers + Actions + + + + {controls.pagedRows.map((group) => { + const groupKey = `${group.returnType.toLowerCase()}-${group.bookingId}`; + const isOpen = expanded === groupKey; + return ( + + + + setExpanded(isOpen ? null : groupKey)} + > + {isOpen ? : } + + + + {group.bookingRef} + + {group.companyName ?? "—"} + + + {group.returnType} + + + + {group.containers.length} container{group.containers.length !== 1 ? "s" : ""} + + + + + + {isOpen && ( + + +
+ + + Container + Size + Type + + + + {group.containers.map((container) => ( + + {container.containerNumber} + {container.size ?? "—"} + {container.type ?? "—"} + + ))} + +
+ + + )} + + ); + })} + + +
+ + + )} + + setReturnModalOpen(false)} + group={activeGroup} + onSubmit={(payload) => createReturnsMutation.mutate(payload)} + loading={createReturnsMutation.isPending} + /> +
+ ); +} + +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([]); + const [returnDate, setReturnDate] = useState(new Date().toISOString().split("T")[0]); + const [warehouse, setWarehouse] = useState(null); + const [condition, setCondition] = useState(""); + const [handoverNote, setHandoverNote] = useState(""); + + 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 ( + + {group && ( + + + {group.bookingRef} + + {group.returnType} + + + +
+ + Select containers to return: + + + {group.containers.map((container) => ( + { + if (e.currentTarget.checked) { + setSelectedContainers([...selectedContainers, container.key]); + } else { + setSelectedContainers(selectedContainers.filter((c) => c !== container.key)); + } + }} + /> + ))} + +
+ + setReturnDate(e.target.value)} + style={{ padding: "8px", borderRadius: "4px", border: "1px solid #ccc" }} + required + /> + +