From 0c0de284b03cfbb098ca1eafb1aff74bf249b1f0 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 20 Jul 2026 12:35:51 +0000 Subject: [PATCH 1/2] feat(warehouse): show the trucks in the yard, not just a count of them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-truck self-haul had no list anywhere on the warehouse side. The ops dashboard counted trucks on site and offered no way to open the list, and the inventory table showed a blank plate on exactly the bookings that have several trucks: it read booking.customer_truck_plate_number, which multi-truck self-haul leaves null because plates live in customer_truck_assignments. Booking BK-2026-000033 has a truck and a driver on file and displayed neither. Adds a Trucks on Site page listing every truck that has arrived and not yet departed, across bookings, with plate, driver, booking, customer, containers and dwell time. It covers both haulage paths because the gate does — a customer's own truck and an EDR last-mile truck reach the same barrier — and flags anything sitting over four hours. It lives under Warehouse Management rather than Imports or Exports, since the yard is not per-direction. The inventory queries now read plates and drivers from the assignments and keep the booking columns as the fallback for single-truck bookings written before that table existed. Both new statements were EXPLAIN-validated against the live schema; the plate fix returns the data that was previously null. Co-Authored-By: Claude Opus 4.8 --- .../warehouse-inventory.controller.ts | 9 + .../warehouses/warehouse-inventory.service.ts | 109 ++++++++++- apps/edr-freight-web/backoffice/src/App.tsx | 9 + .../backoffice/src/constants/URLS.ts | 1 + .../backoffice/src/hooks/useWarehouses.ts | 9 + .../src/pages/warehouses/TrucksOnSitePage.tsx | 176 ++++++++++++++++++ .../src/services/warehouse.service.ts | 4 + .../backoffice/src/types/warehouse.ts | 18 ++ 8 files changed, 329 insertions(+), 6 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx 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 = () => { } /> } /> } /> + } /> } /> } /> `/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; From 7b777792e38b7ab87f6587fe709cbfef5a18b8f1 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 20 Jul 2026 12:52:31 +0000 Subject: [PATCH 2/2] feat(warehouse): expand an inventory row to see which containers ride which truck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inventory table has one row per inventory item, so on a self-haul booking with several trucks there was no way to tell which container sat on which truck without opening a document. The plate column now lists every plate, but not the mapping. Each row with a customer truck gets a chevron that opens a per-truck breakdown: plate, driver, type, the containers it carries, and whether it has arrived. Bulk trucks show "Bulk" — they haul loose tonnage, not containers. The breakdown fetches only when opened, so a table nobody expands costs no extra requests, and it reuses the existing per-booking trucks endpoint rather than widening the inventory query. Co-Authored-By: Claude Opus 4.8 --- .../warehouses/TruckBreakdownRow.tsx | 113 ++++++++++++++++++ .../warehouses/WarehouseInventoryTable.tsx | 53 +++++++- 2 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/TruckBreakdownRow.tsx diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/TruckBreakdownRow.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/TruckBreakdownRow.tsx new file mode 100644 index 000000000..d20f30a6b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/TruckBreakdownRow.tsx @@ -0,0 +1,113 @@ +import { useQuery } from "@tanstack/react-query"; +import { Badge, Group, Loader, Table, Text } from "@mantine/core"; + +import { warehouseService } from "@/services/warehouse.service"; + +/** + * The trucks carrying one booking's cargo, and which containers ride each. + * + * A self-haul booking can have several trucks, each with 1–2 containers, but + * the inventory table has one row per inventory item — so which container sits + * on which truck was never visible without opening a document. Fetched lazily: + * only an expanded row costs a request. + */ +export function TruckBreakdownRow({ + bookingId, + colSpan, +}: { + bookingId: string; + colSpan: number; +}) { + const { data: trucks = [], isLoading } = useQuery({ + queryKey: ["booking-customer-trucks", bookingId], + queryFn: () => 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} + ); })}