diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 77e67f210..eda320c5b 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -121,6 +121,7 @@ import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage"; import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage"; +import ImportTrucksPage from "./pages/warehouses/ImportTrucksPage"; import ImportWarehouseFlowPage from "./pages/warehouses/ImportWarehouseFlowPage"; import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPage"; import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; @@ -404,6 +405,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.warehouseInventory.view, }, + { + label: "Import Trucks", + href: "/dashboard/import-trucks", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, { label: "Dispatch Queue", href: "/dashboard/dispatch-queue", @@ -1052,6 +1059,7 @@ const App = () => { } /> } /> } /> + } /> } /> } /> + `${Number(amount).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${currency === "ETB" ? "ETB" : currency}`; + +interface BookingGroup { + bookingId: string; + bookingReference: string; + customerName: string | null; + trainSchedule: string | null; + status: string; + arrivalTime: string | null; + rows: ImportUnloadedItem[]; +} + +/** One collapsed line per booking; its inventory rows travel with it for the fee lookup. */ +function groupByBooking(items: ImportUnloadedItem[]): BookingGroup[] { + const groups = new Map(); + for (const item of items) { + if (!item.bookingId) continue; + const existing = groups.get(item.bookingId); + if (existing) { + existing.rows.push(item); + // Mixed statuses across a booking's rows are normal mid-pickup — show the + // least-advanced one so the row reads as "still has work on it". + if (existing.status !== item.currentStatus) existing.status = "MIXED"; + continue; + } + groups.set(item.bookingId, { + bookingId: item.bookingId, + bookingReference: item.bookingReference ?? item.bookingId, + customerName: item.customerName, + trainSchedule: item.trainSchedule, + status: item.currentStatus, + arrivalTime: item.arrivalTime, + rows: [item], + }); + } + return [...groups.values()]; +} + +interface TruckRow { + key: string; + plate: string; + driver: string | null; + truckType: string | null; + containers: string[]; + /** Inventory rows this truck carries — the ids the documents and fees hang off. */ + inventoryIds: string[]; + weight: number; + demurrage: number; + storage: number; + vehicleId: string | null; +} + +/** + * Haulage mode is decided by which list comes back non-empty — a booking is + * either EDR last mile or customer self-haul, never both. + */ +function TruckRows({ group }: { group: BookingGroup }) { + const { toast } = useToast(); + const [busy, setBusy] = useState(false); + const [inspectId, setInspectId] = useState(null); + const [detentionOpen, setDetentionOpen] = useState(false); + + const edrQuery = useQuery({ + queryKey: ["booking-edr-trucks", group.bookingId], + queryFn: () => warehouseService.getLastMileTrucks(group.bookingId), + }); + const edrTrucks = edrQuery.data ?? []; + + const customerQuery = useQuery({ + queryKey: ["booking-customer-trucks", group.bookingId], + queryFn: () => warehouseService.getCustomerTrucks(group.bookingId), + enabled: edrQuery.isSuccess && edrTrucks.length === 0, + }); + const customerTrucks = customerQuery.data ?? []; + const isEdr = edrTrucks.length > 0; + + const lastMileId = edrTrucks[0]?.lastMileId ?? null; + + const detentionQuery = useQuery({ + queryKey: ["truck-detention-preview-import-trucks", lastMileId], + queryFn: () => lastMileService.truckDetentionPreview(lastMileId as string).then((r) => r.data), + enabled: Boolean(lastMileId), + }); + const detentionByVehicle = new Map( + (detentionQuery.data?.groups ?? []).map((g) => [g.vehicleId ?? "", g]), + ); + + const lastMileRecordQuery = useQuery({ + queryKey: ["last-mile-record-import-trucks", lastMileId], + queryFn: () => lastMileService.getById(lastMileId as string).then((r) => r.data), + enabled: detentionOpen && Boolean(lastMileId), + }); + + // Same per-inventory-row preview the accrual dashboard bills off; the queue + // rows already ARE this booking's import inventory, so no second list fetch. + const feeQueries = useQueries({ + queries: group.rows.map((row) => + api.warehouses.feePreview.queryOptions({ + input: { inventoryId: row.id, billingCurrency: "USD" }, + }), + ), + }); + const feesByInventory = new Map(group.rows.map((row, i) => [row.id, feeQueries[i]?.data ?? []])); + const feeCurrency = feeQueries.flatMap((q) => q.data ?? [])[0]?.currency ?? "USD"; + const rowByContainer = new Map( + group.rows.filter((r) => r.containerNumber).map((r) => [r.containerNumber as string, r]), + ); + + /** Fees follow the container onto the truck; a bulk truck carries the whole booking. */ + const costsFor = (containers: string[]) => { + const matched = containers.map((c) => rowByContainer.get(c)).filter(Boolean) as ImportUnloadedItem[]; + const rows = matched.length > 0 ? matched : group.rows; + const fees = rows.flatMap((r) => feesByInventory.get(r.id) ?? []); + const sum = (type: string) => + fees.filter((f) => f.ruleType === type).reduce((total, f) => total + Number(f.amount || 0), 0); + return { + inventoryIds: rows.map((r) => r.id), + weight: rows.reduce((total, r) => total + (Number(r.weight) || 0), 0), + demurrage: sum("DEMURRAGE_FEE"), + storage: sum("STORAGE_FEE"), + }; + }; + + const fromEdr = (t: LastMileArrivalTruck): TruckRow => { + const containers = t.containerNumber ? [t.containerNumber] : []; + return { + key: t.vehicleId, + plate: [t.truckPlateNumber, t.trailerPlateNumber].filter(Boolean).join(" + ") || "—", + driver: t.driverName, + truckType: t.truckType, + containers, + vehicleId: t.vehicleId, + ...costsFor(containers), + }; + }; + const fromCustomer = (t: Freight.ICustomerTruck): TruckRow => { + const containers = (t.containers ?? []).map((c) => c.containerNumber); + return { + key: t.id, + plate: t.plateNumber, + driver: t.driverName, + truckType: t.truckType, + containers, + vehicleId: null, + ...costsFor(containers), + }; + }; + const trucks: TruckRow[] = isEdr ? edrTrucks.map(fromEdr) : customerTrucks.map(fromCustomer); + + const openDocument = async ( + kind: "release" | "handover", + inventoryId: string, + label: string, + ) => { + setBusy(true); + const pdfWindow = window.open("", "_blank"); + try { + const response = + kind === "release" + ? await warehouseService.downloadReleaseDocument(inventoryId) + : await warehouseService.downloadHandoverDocument(inventoryId); + openPdfBlob(response.data, `${kind}-${group.bookingReference}.pdf`, pdfWindow); + } catch (error) { + pdfWindow?.close(); + toast({ + variant: "destructive", + title: `${label} failed`, + description: await extractDownloadErrorMessage(error), + }); + } finally { + setBusy(false); + } + }; + + if (edrQuery.isLoading || (customerQuery.isFetching && customerTrucks.length === 0)) { + return ( + + + + + + Loading trucks… + + + + + ); + } + + if (trucks.length === 0) { + return ( + + + + No truck assigned to this booking yet. + + + + ); + } + + return ( + <> + {trucks.map((t, idx) => { + const detention = t.vehicleId ? detentionByVehicle.get(t.vehicleId) : undefined; + const primaryId = t.inventoryIds[0] ?? null; + return ( + + + + + {t.plate} + + {t.driver && ( + + {t.driver} + + )} + + + + {isEdr ? "EDR" : "Customer"} + + + {t.truckType ?? "—"} + + + + {t.containers.length ? t.containers.join(", ") : "Bulk"} + + {formatNumber(t.weight)} + {money(t.demurrage, feeCurrency)} + {money(t.storage, feeCurrency)} + + {!isEdr || !detention ? ( + + — + + ) : ( + + + {detention.chargeableDays ?? 0}d ·{" "} + {money(Number(detention.amount ?? 0), detentionQuery.data?.currency ?? "USD")} + + {detention.endIsOpen && ( + + open + + )} + {!detention.ruleId && ( + + + no rule + + + )} + + )} + + + + + + + + + + } + disabled={!primaryId} + onClick={() => primaryId && openDocument("release", primaryId, "Exit paper")} + > + Exit paper + + } + disabled={!primaryId} + onClick={() => primaryId && openDocument("handover", primaryId, "Handover")} + > + Handover + + } + disabled={!primaryId} + onClick={() => setInspectId(primaryId)} + > + Inspect / report + + {isEdr && ( + <> + + } + disabled={!lastMileId} + onClick={() => setDetentionOpen(true)} + > + Detention times… + + + )} + + + {/* Modals portal out of the table, so one mount for the whole + booking hangs off the first truck's cell. */} + {idx === 0 && ( + <> + setInspectId(null)} + inventoryId={inspectId} + /> + {isEdr && ( + setDetentionOpen(false)} + record={lastMileRecordQuery.data ?? null} + /> + )} + + )} + + + ); + })} + + ); +} + +export default function ImportTrucksPage() { + const { data: items = [], isLoading } = useQuery( + api.warehouses.importUnloadedQueue.queryOptions({}), + ); + const [expanded, setExpanded] = useState(null); + + const groups = useMemo(() => groupByBooking(items), [items]); + const controls = useListControls(groups, { + searchKeys: ["bookingReference", "customerName", "trainSchedule"], + dateKey: "arrivalTime", + }); + + return ( + + + + + + {isLoading ? ( + + + + ) : controls.pagedRows.length === 0 ? ( + + No unloaded import bookings. They appear here after Auto Unload on an arrived train. + + ) : ( + <> + + + + + + Plate + Type + Containers + Weight + Demurrage + Storage + Detention + Actions + + + + {controls.pagedRows.map((g) => { + const isOpen = expanded === g.bookingId; + return ( + + + + setExpanded(isOpen ? null : g.bookingId)} + > + {isOpen ? : } + + + + + + {g.bookingReference} + + + {g.customerName ?? "—"} + + + Train: {g.trainSchedule ?? "—"} + + + {g.status.replace(/_/g, " ")} + + + {g.rows.length} item{g.rows.length === 1 ? "" : "s"} + + + + + {isOpen && } + + ); + })} + +
+
+ + + )} +
+ ); +}