mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user