feat(warehouse): show the trucks in the yard, not just a count of them

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 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-20 12:35:51 +00:00
parent cd80a29602
commit 0c0de284b0
8 changed files with 329 additions and 6 deletions

View File

@@ -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' })

View File

@@ -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",

View File

@@ -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: <LayoutDashboard />,
},
{
// 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: <Truck />,
},
{
label: "Warehouses",
href: "/dashboard/warehouses",
@@ -960,6 +968,7 @@ const App = () => {
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
<Route path="loading-queue" element={<LoadingQueuePage />} />
<Route path="intercity" element={<IntercityPage />} />
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
<Route

View File

@@ -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",

View File

@@ -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;

View File

@@ -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 (
<Alert variant="light" color="gray">
No trucks on site.
</Alert>
);
}
return (
<Table.ScrollContainer minWidth={980}>
<Table striped highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Plate</Table.Th>
<Table.Th>Haulage</Table.Th>
<Table.Th>Driver</Table.Th>
<Table.Th>Truck type</Table.Th>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Containers</Table.Th>
<Table.Th>On site</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => (
<Table.Tr key={`${row.source}-${row.assignmentId}`}>
<Table.Td>
<Text size="sm" fw={600}>
{row.plateNumber ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
radius="sm"
variant="light"
color={row.source === "CUSTOMER" ? "blue" : "edr-green"}
>
{row.source === "CUSTOMER" ? "Customer" : "EDR"}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{row.driverName ?? "—"}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{row.truckType ?? "—"}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{row.bookingReference ?? "—"}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{row.customerName ?? "—"}</Text>
</Table.Td>
<Table.Td>
{/* Bulk trucks carry no containers — they haul loose tonnage. */}
<Text size="sm">{row.containers ?? "Bulk"}</Text>
</Table.Td>
<Table.Td>
{isLongDwell(row.arrivedAt) ? (
<Tooltip
label={`On site over ${LONG_DWELL_HOURS}h — arrived ${new Date(row.arrivedAt as string).toLocaleString()}`}
withArrow
>
<Text size="sm" c="red" fw={600}>
{dwell(row.arrivedAt)}
</Text>
</Tooltip>
) : (
<Text size="sm">{dwell(row.arrivedAt)}</Text>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
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 (
<PageContainer>
<PageHeader
title="Trucks on site"
subtitle="Arrived at the yard and not yet left — customer self-haul and EDR last-mile."
/>
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<SegmentedControl
size="xs"
value={source}
onChange={(v) => setSource(v as typeof source)}
data={[
{ label: `All (${trucks.length})`, value: "ALL" },
{ label: `Customer (${customerCount})`, value: "CUSTOMER" },
{ label: `EDR (${edrCount})`, value: "EDR" },
]}
/>
<TextInput
size="xs"
w={280}
placeholder="Plate, driver, booking, container…"
leftSection={<Search size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Group>
{isLoading ? <Text size="sm">Loading</Text> : <Rows rows={rows} />}
</PageContainer>
);
}

View File

@@ -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<WarehouseOpsStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.OPS_STATS),
/** Trucks inside the yard right now — the list behind the trucksOnSite figure. */
trucksOnSite: () =>
apiClient.get<TruckOnSite[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.TRUCKS_ON_SITE),
throughput: (granularity: 'week' | 'month' | 'year') =>
apiClient.get<WarehouseThroughputPoint[]>(
URL_CONSTANTS.WAREHOUSE_INVENTORY.THROUGHPUT(granularity),

View File

@@ -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;