This commit is contained in:
Roba Boru
2026-07-20 16:10:47 +03:00
10 changed files with 492 additions and 9 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

@@ -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 12 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 (
<Table.Tr>
<Table.Td colSpan={colSpan} bg="var(--mantine-color-gray-0)">
{isLoading ? (
<Group gap="xs" py="xs">
<Loader size="xs" />
<Text size="xs" c="dimmed">
Loading trucks
</Text>
</Group>
) : trucks.length === 0 ? (
<Text size="xs" c="dimmed" py="xs">
No customer trucks assigned to this booking.
</Text>
) : (
<Table verticalSpacing={4} withRowBorders={false}>
<Table.Thead>
<Table.Tr>
<Table.Th>
<Text size="xs" c="dimmed">
Truck
</Text>
</Table.Th>
<Table.Th>
<Text size="xs" c="dimmed">
Driver
</Text>
</Table.Th>
<Table.Th>
<Text size="xs" c="dimmed">
Type
</Text>
</Table.Th>
<Table.Th>
<Text size="xs" c="dimmed">
Containers
</Text>
</Table.Th>
<Table.Th>
<Text size="xs" c="dimmed">
Status
</Text>
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{trucks.map((truck) => (
<Table.Tr key={truck.id}>
<Table.Td>
<Text size="xs" fw={600}>
{truck.plateNumber}
</Text>
</Table.Td>
<Table.Td>
<Text size="xs">{truck.driverName}</Text>
</Table.Td>
<Table.Td>
<Text size="xs">{truck.truckType}</Text>
</Table.Td>
<Table.Td>
{/* Bulk trucks carry loose tonnage, not containers. */}
<Text size="xs">
{truck.containers?.length
? truck.containers.map((c) => c.containerNumber).join(", ")
: "Bulk"}
</Text>
</Table.Td>
<Table.Td>
<Badge
size="xs"
radius="sm"
variant="light"
color={truck.arrivedAt ? "edr-green" : "gray"}
>
{truck.arrivedAt
? `Arrived ${new Date(truck.arrivedAt).toLocaleString()}`
: "Not arrived"}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Table.Td>
</Table.Tr>
);
}

View File

@@ -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<Set<string>>(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({
/>
</Table.Th>
)}
{/* Expander for the per-truck breakdown. */}
<Table.Th w={32} />
<Table.Th>Booking</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Facility</Table.Th>
@@ -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 (
<Table.Tr key={item.id}>
<Fragment key={item.id}>
<Table.Tr>
{selectable && (
<Table.Td>
<Checkbox
@@ -185,6 +206,23 @@ export function WarehouseInventoryTable({
/>
</Table.Td>
)}
<Table.Td>
{hasCustomerTrucks ? (
<Tooltip
label={isExpanded ? 'Hide trucks' : 'Show which containers ride which truck'}
withArrow
>
<ActionIcon
variant="subtle"
size="sm"
aria-label={isExpanded ? 'Hide trucks' : 'Show trucks'}
onClick={() => toggleExpanded(item.bookingId as string)}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ActionIcon>
</Tooltip>
) : null}
</Table.Td>
<Table.Td>
{item.bookingReference || item.booking?.reference || item.bookingId ? (
<Tooltip label={item.bookingId ?? ''} withArrow disabled={!item.bookingId}>
@@ -309,6 +347,15 @@ export function WarehouseInventoryTable({
</Group>
</Table.Td>
</Table.Tr>
{isExpanded && item.bookingId ? (
<TruckBreakdownRow
bookingId={item.bookingId}
// Expander + every data column + actions, plus the checkbox
// when the table is selectable.
colSpan={selectable ? 14 : 13}
/>
) : null}
</Fragment>
);
})}
</Table.Tbody>

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;