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
+
+
+ )}
+
+ )}
+
+
+
+ {/* 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 && }
+
+ );
+ })}
+
+
+
+
+ >
+ )}
+
+ );
+}