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

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