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;