mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'dev' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -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' })
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 1–2 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user