diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
index e92808274..2373cb6d8 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
@@ -69,6 +69,15 @@ export class WarehouseInventoryController {
return this.inventoryService.opsStats();
}
+ @Get('trucks-on-site')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
+ @ApiOperation({
+ summary: 'Trucks currently in the yard (customer self-haul + EDR last-mile)',
+ })
+ trucksOnSite() {
+ return this.inventoryService.trucksOnSite();
+ }
+
@Get('zone-occupancy')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' })
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
index ee1438fcf..5fe25416f 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
@@ -410,6 +410,77 @@ export class WarehouseInventoryService {
* - trucksOnSite: customer trucks arrived but not departed
* - itemsAging: in-warehouse items older than 7 days (demurrage risk)
*/
+ /**
+ * Every truck currently inside the yard, across all bookings — the list behind
+ * the `trucksOnSite` figure on the ops dashboard, which until now could only
+ * be counted and never opened.
+ *
+ * Covers both haulage paths because the gate does: a customer's own truck and
+ * an EDR last-mile truck arrive at the same barrier and need the same paper.
+ * "On site" means arrived and not yet departed.
+ */
+ async trucksOnSite(): Promise<
+ Array<{
+ source: 'CUSTOMER' | 'EDR';
+ assignmentId: string;
+ plateNumber: string | null;
+ driverName: string | null;
+ truckType: string | null;
+ arrivedAt: string | null;
+ bookingId: string;
+ bookingReference: string | null;
+ customerName: string | null;
+ containers: string | null;
+ }>
+ > {
+ return this.dataSource.query(
+ `SELECT 'CUSTOMER' AS "source",
+ a.id AS "assignmentId",
+ a.plate_number AS "plateNumber",
+ a.driver_name AS "driverName",
+ a.truck_type AS "truckType",
+ a.arrived_at AS "arrivedAt",
+ b.id AS "bookingId",
+ b.reference AS "bookingReference",
+ company.name AS "customerName",
+ (SELECT string_agg(c.container_number, ', ' ORDER BY c.container_number)
+ FROM freight.customer_truck_containers c
+ WHERE c.assignment_id = a.id AND c.deleted_at IS NULL) AS "containers"
+ FROM freight.customer_truck_assignments a
+ JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
+ LEFT JOIN freight.companies company ON company.id = b.company_id
+ WHERE a.deleted_at IS NULL
+ AND a.arrived_at IS NOT NULL
+ AND a.departed_at IS NULL
+
+ UNION ALL
+
+ SELECT 'EDR' AS "source",
+ va.id AS "assignmentId",
+ COALESCE(v.plate_number, v.power_plate_no) AS "plateNumber",
+ NULLIF(TRIM(CONCAT_WS(' ', d.first_name, d.last_name)), '') AS "driverName",
+ v.vehicle_type AS "truckType",
+ va.arrived_at AS "arrivedAt",
+ b.id AS "bookingId",
+ b.reference AS "bookingReference",
+ company.name AS "customerName",
+ (SELECT string_agg(lvc.container_number, ', ' ORDER BY lvc.container_number)
+ FROM freight.last_mile_vehicle_containers lvc
+ WHERE lvc.assignment_id = va.id AND lvc.deleted_at IS NULL) AS "containers"
+ FROM freight.last_mile_vehicle_assignments va
+ JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
+ JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL
+ LEFT JOIN freight.vehicles v ON v.id = va.vehicle_id
+ LEFT JOIN freight.drivers d ON d.id = v.assigned_driver_id
+ LEFT JOIN freight.companies company ON company.id = b.company_id
+ WHERE va.deleted_at IS NULL
+ AND va.arrived_at IS NOT NULL
+ AND va.departed_at IS NULL
+
+ ORDER BY "arrivedAt" ASC`,
+ );
+ }
+
async opsStats(): Promise<{
receivedToday: number;
receivedYesterday: number;
@@ -1202,8 +1273,18 @@ export class WarehouseInventoryService {
driver.phone_number AS "firstMileDriverPhone",
driver.license_number AS "firstMileDriverLicenseNumber",
v.vehicle_type AS "firstMileTruckType",
- b.customer_truck_plate_number AS "customerTruckPlateNumber",
- b.customer_truck_driver_name AS "customerTruckDriverName",
+ -- Multi-truck self-haul writes plates/drivers to
+ -- customer_truck_assignments and leaves the booking columns null,
+ -- so read the assignments first and keep the legacy column as the
+ -- fallback for single-truck bookings written before that table.
+ COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
+ FROM freight.customer_truck_assignments cta
+ WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
+ b.customer_truck_plate_number) AS "customerTruckPlateNumber",
+ COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
+ FROM freight.customer_truck_assignments cta
+ WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
+ b.customer_truck_driver_name) AS "customerTruckDriverName",
b.customer_truck_type AS "customerTruckType",
b.customer_truck_container_number AS "customerTruckContainerNumber",
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
@@ -1334,8 +1415,14 @@ export class WarehouseInventoryService {
driver.phone_number AS "firstMileDriverPhone",
driver.license_number AS "firstMileDriverLicenseNumber",
v.vehicle_type AS "firstMileTruckType",
- b.customer_truck_plate_number AS "customerTruckPlateNumber",
- b.customer_truck_driver_name AS "customerTruckDriverName",
+ COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
+ FROM freight.customer_truck_assignments cta
+ WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
+ b.customer_truck_plate_number) AS "customerTruckPlateNumber",
+ COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
+ FROM freight.customer_truck_assignments cta
+ WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
+ b.customer_truck_driver_name) AS "customerTruckDriverName",
b.customer_truck_type AS "customerTruckType",
b.customer_truck_container_number AS "customerTruckContainerNumber",
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
@@ -1794,8 +1881,18 @@ export class WarehouseInventoryService {
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
- b.customer_truck_plate_number AS "customerTruckPlateNumber",
- b.customer_truck_driver_name AS "customerTruckDriverName",
+ -- Multi-truck self-haul writes plates/drivers to
+ -- customer_truck_assignments and leaves the booking columns null,
+ -- so read the assignments first and keep the legacy column as the
+ -- fallback for single-truck bookings written before that table.
+ COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
+ FROM freight.customer_truck_assignments cta
+ WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
+ b.customer_truck_plate_number) AS "customerTruckPlateNumber",
+ COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
+ FROM freight.customer_truck_assignments cta
+ WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
+ b.customer_truck_driver_name) AS "customerTruckDriverName",
b.customer_truck_type AS "customerTruckType",
b.customer_truck_container_number AS "customerTruckContainerNumber",
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx
index 813595690..86f5bf6e6 100644
--- a/apps/edr-freight-web/backoffice/src/App.tsx
+++ b/apps/edr-freight-web/backoffice/src/App.tsx
@@ -125,6 +125,7 @@ import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
import IntercityPage from "./pages/warehouses/IntercityPage";
+import TrucksOnSitePage from "./pages/warehouses/TrucksOnSitePage";
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
@@ -465,6 +466,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
href: "/dashboard/warehouse-dashboard",
icon: ,
},
+ {
+ // Yard-wide, not per-direction: the gate sees import and export
+ // trucks at the same barrier.
+ label: "Trucks on Site",
+ href: "/dashboard/trucks-on-site",
+ icon: ,
+ },
{
label: "Warehouses",
href: "/dashboard/warehouses",
@@ -960,6 +968,7 @@ const App = () => {
} />
} />
} />
+ } />
} />
} />
warehouseService.getCustomerTrucks(bookingId),
+ });
+
+ return (
+
+
+ {isLoading ? (
+
+
+
+ Loading trucks…
+
+
+ ) : trucks.length === 0 ? (
+
+ No customer trucks assigned to this booking.
+
+ ) : (
+
+
+
+
+
+ Truck
+
+
+
+
+ Driver
+
+
+
+
+ Type
+
+
+
+
+ Containers
+
+
+
+
+ Status
+
+
+
+
+
+ {trucks.map((truck) => (
+
+
+
+ {truck.plateNumber}
+
+
+
+ {truck.driverName}
+
+
+ {truck.truckType}
+
+
+ {/* Bulk trucks carry loose tonnage, not containers. */}
+
+ {truck.containers?.length
+ ? truck.containers.map((c) => c.containerNumber).join(", ")
+ : "Bulk"}
+
+
+
+
+ {truck.arrivedAt
+ ? `Arrived ${new Date(truck.arrivedAt).toLocaleString()}`
+ : "Not arrived"}
+
+
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx
index 060dc600e..48ce59c78 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx
@@ -1,6 +1,6 @@
-import { useState, type MouseEvent } from 'react';
+import { Fragment, useState, type MouseEvent } from 'react';
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
-import { ArrowRightLeft, ClipboardList, Coins, Download, Eye, FileText, History, MapPin } from 'lucide-react';
+import { ArrowRightLeft, ChevronDown, ChevronRight, ClipboardList, Coins, Download, Eye, FileText, History, MapPin } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
@@ -10,6 +10,7 @@ import {
type WarehouseInventoryItem,
} from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
+import { TruckBreakdownRow } from './TruckBreakdownRow';
import { extractDownloadErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
import { openPdfBlob } from './pdf';
@@ -120,6 +121,16 @@ export function WarehouseInventoryTable({
someSelected,
}: WarehouseInventoryTableProps) {
const selectable = Boolean(onToggleSelect);
+ // Bookings whose truck breakdown is open. Expanded rows fetch on demand, so a
+ // closed table costs nothing extra.
+ const [expanded, setExpanded] = useState>(new Set());
+ const toggleExpanded = (bookingId: string) =>
+ setExpanded((prev) => {
+ const next = new Set(prev);
+ if (next.has(bookingId)) next.delete(bookingId);
+ else next.add(bookingId);
+ return next;
+ });
if (items.length === 0) {
return (
@@ -144,6 +155,8 @@ export function WarehouseInventoryTable({
/>
)}
+ {/* Expander for the per-truck breakdown. */}
+
Booking
GRN
Facility
@@ -174,8 +187,16 @@ export function WarehouseInventoryTable({
(!item.booking?.tradeDirection || item.booking.tradeDirection === 'IMPORT');
const handoverReference = handoverDocumentReference(item);
+ // Only offer the breakdown where there is one: a plate means at
+ // least one customer truck is on the booking.
+ const hasCustomerTrucks = Boolean(
+ item.bookingId && item.booking?.customerTruckPlateNumber?.trim(),
+ );
+ const isExpanded = Boolean(item.bookingId && expanded.has(item.bookingId));
+
return (
-
+
+
{selectable && (
)}
+
+ {hasCustomerTrucks ? (
+
+ toggleExpanded(item.bookingId as string)}
+ >
+ {isExpanded ? : }
+
+
+ ) : null}
+
{item.bookingReference || item.booking?.reference || item.bookingId ? (
@@ -309,6 +347,15 @@ export function WarehouseInventoryTable({
+ {isExpanded && item.bookingId ? (
+
+ ) : null}
+
);
})}
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index 8bf394151..60607f8f2 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -492,6 +492,7 @@ export const URL_CONSTANTS = {
RESERVE: "/warehouse-inventory/reserve",
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
OPS_STATS: "/warehouse-inventory/ops-stats",
+ TRUCKS_ON_SITE: "/warehouse-inventory/trucks-on-site",
THROUGHPUT: (granularity: 'week' | 'month' | 'year') =>
`/warehouse-inventory/throughput?granularity=${granularity}`,
DWELL_STATS: "/warehouse-inventory/dwell-stats",
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
index 9327e5ee0..54f1cc4d3 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
@@ -154,6 +154,15 @@ export function useWarehouseOpsStats() {
});
}
+/** Trucks in the yard right now — refreshes with the rest of the ops widgets. */
+export function useTrucksOnSite() {
+ return useQuery({
+ queryKey: ['warehouse-inventory', 'trucks-on-site'],
+ queryFn: () => warehouseService.trucksOnSite().then((r) => r.data),
+ refetchInterval: DASHBOARD_REFETCH_MS,
+ });
+}
+
/** How often the live warehouse dashboard widgets auto-refresh (ms). */
export const DASHBOARD_REFETCH_MS = 60_000;
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx
new file mode 100644
index 000000000..9d2ae0e63
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx
@@ -0,0 +1,176 @@
+import { useMemo, useState } from "react";
+import {
+ Alert,
+ Badge,
+ Group,
+ SegmentedControl,
+ Table,
+ Text,
+ TextInput,
+ Tooltip,
+} from "@mantine/core";
+import { Search } from "lucide-react";
+
+import { PageContainer, PageHeader } from "@/components/page";
+import { useTrucksOnSite } from "@/hooks/useWarehouses";
+import type { TruckOnSite } from "@/types/warehouse";
+
+/**
+ * Every truck inside the yard right now, across all bookings.
+ *
+ * The gate's question is "which trucks are here", not "which bookings have
+ * trucks" — the ops dashboard could only count them, never open the list. Both
+ * haulage paths appear because the same barrier handles both: a customer's own
+ * truck and an EDR last-mile truck.
+ */
+
+/** How long the truck has been on site — the number the gate actually chases. */
+function dwell(arrivedAt: string | null): string {
+ if (!arrivedAt) return "—";
+ const minutes = Math.floor((Date.now() - new Date(arrivedAt).getTime()) / 60_000);
+ if (minutes < 1) return "just now";
+ if (minutes < 60) return `${minutes}m`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ${minutes % 60}m`;
+ return `${Math.floor(hours / 24)}d ${hours % 24}h`;
+}
+
+/** Long dwell means a truck is sitting at the gate — worth flagging, not hiding. */
+const LONG_DWELL_HOURS = 4;
+
+function isLongDwell(arrivedAt: string | null): boolean {
+ if (!arrivedAt) return false;
+ return Date.now() - new Date(arrivedAt).getTime() > LONG_DWELL_HOURS * 3_600_000;
+}
+
+function Rows({ rows }: { rows: TruckOnSite[] }) {
+ if (rows.length === 0) {
+ return (
+
+ No trucks on site.
+
+ );
+ }
+ return (
+
+
+
+
+ Plate
+ Haulage
+ Driver
+ Truck type
+ Booking
+ Customer
+ Containers
+ On site
+
+
+
+ {rows.map((row) => (
+
+
+
+ {row.plateNumber ?? "—"}
+
+
+
+
+ {row.source === "CUSTOMER" ? "Customer" : "EDR"}
+
+
+
+ {row.driverName ?? "—"}
+
+
+ {row.truckType ?? "—"}
+
+
+ {row.bookingReference ?? "—"}
+
+
+ {row.customerName ?? "—"}
+
+
+ {/* Bulk trucks carry no containers — they haul loose tonnage. */}
+ {row.containers ?? "Bulk"}
+
+
+ {isLongDwell(row.arrivedAt) ? (
+
+
+ {dwell(row.arrivedAt)}
+
+
+ ) : (
+ {dwell(row.arrivedAt)}
+ )}
+
+
+ ))}
+
+
+
+ );
+}
+
+export default function TrucksOnSitePage() {
+ const { data: trucks = [], isLoading } = useTrucksOnSite();
+ const [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL");
+ const [search, setSearch] = useState("");
+
+ const rows = useMemo(() => {
+ const term = search.trim().toLowerCase();
+ return trucks
+ .filter((t) => source === "ALL" || t.source === source)
+ .filter((t) =>
+ !term
+ ? true
+ : [t.plateNumber, t.driverName, t.bookingReference, t.customerName, t.containers]
+ .some((field) => field?.toLowerCase().includes(term)),
+ );
+ }, [trucks, source, search]);
+
+ const customerCount = trucks.filter((t) => t.source === "CUSTOMER").length;
+ const edrCount = trucks.length - customerCount;
+
+ return (
+
+
+
+
+ setSource(v as typeof source)}
+ data={[
+ { label: `All (${trucks.length})`, value: "ALL" },
+ { label: `Customer (${customerCount})`, value: "CUSTOMER" },
+ { label: `EDR (${edrCount})`, value: "EDR" },
+ ]}
+ />
+ }
+ value={search}
+ onChange={(e) => setSearch(e.currentTarget.value)}
+ />
+
+
+ {isLoading ? Loading… : }
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
index 1f2b9c51b..9eb089f19 100644
--- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
@@ -5,6 +5,7 @@ import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
import type {
ZoneOccupancy,
+ TruckOnSite,
WarehouseOpsStats,
WarehouseThroughputPoint,
WarehouseDwellStats,
@@ -404,6 +405,9 @@ export const warehouseService = {
),
opsStats: () =>
apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.OPS_STATS),
+ /** Trucks inside the yard right now — the list behind the trucksOnSite figure. */
+ trucksOnSite: () =>
+ apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.TRUCKS_ON_SITE),
throughput: (granularity: 'week' | 'month' | 'year') =>
apiClient.get(
URL_CONSTANTS.WAREHOUSE_INVENTORY.THROUGHPUT(granularity),
diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
index 197b1974f..b9c4a6a88 100644
--- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts
+++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
@@ -1121,6 +1121,24 @@ export interface WarehouseOpsStats {
itemsAging: number;
}
+/**
+ * One truck inside the yard. Both haulage paths appear here because the gate
+ * handles both: `CUSTOMER` is the customer's own truck, `EDR` a last-mile truck.
+ */
+export interface TruckOnSite {
+ source: "CUSTOMER" | "EDR";
+ assignmentId: string;
+ plateNumber: string | null;
+ driverName: string | null;
+ truckType: string | null;
+ arrivedAt: string | null;
+ bookingId: string;
+ bookingReference: string | null;
+ customerName: string | null;
+ /** Comma-separated container numbers; null for bulk. */
+ containers: string | null;
+}
+
/** One bucket of the received-vs-dispatched throughput time series. */
export interface WarehouseThroughputPoint {
periodStart: string;