feat(warehouse): P2 dashboard — gate/dock throughput + live auto-refresh

- gateStats(): items cleared through the gate today, average arrival→gate
  turnaround (hours, 30d), and clearances per hour over the last 24h → new
  GateThroughputCard in the dashboard Performance section.
- Live board: every dashboard query (dashboard, ops, throughput, dwell, cycle,
  on-time, zone occupancy, accrual, gate) now auto-refreshes on a 60s interval,
  with a "Live" indicator in the header.

New endpoint GET /warehouse-inventory/gate-stats (guarded). Deferred (need
upstream data): capacity forecast, labour productivity, WebSocket push, yard map.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-15 14:19:40 +00:00
parent 7be432436b
commit ac90b04be0
9 changed files with 205 additions and 2 deletions

View File

@@ -95,6 +95,13 @@ export class WarehouseInventoryController {
return this.inventoryService.cycleStats();
}
@Get('gate-stats')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Gate/dock throughput: cleared today, turnaround, hourly clearances' })
gateStats() {
return this.inventoryService.gateStats();
}
@Post('auto-unload-arrived')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })

View File

@@ -537,6 +537,58 @@ export class WarehouseInventoryService {
};
}
/**
* Gate / dock throughput: items cleared through the gate today, the average
* arrival→gate-clearance turnaround (hours, last 30 days), and gate clearances
* bucketed per hour over the last 24 hours. Powers the gate throughput card.
*/
async gateStats(): Promise<{
clearedToday: number;
avgTurnaroundHours: number | null;
byHour: Array<{ hour: string; count: number }>;
}> {
const [scalar]: Array<{ clearedToday: number; avgTurnaroundHours: number | null }> =
await this.dataSource.query(
`SELECT
count(*) FILTER (WHERE gate_cleared_at::date = CURRENT_DATE)::int AS "clearedToday",
round(
avg(EXTRACT(EPOCH FROM (gate_cleared_at - arrived_at)) / 3600.0)
FILTER (
WHERE gate_cleared_at IS NOT NULL AND arrived_at IS NOT NULL
AND gate_cleared_at > now() - interval '30 days'
)::numeric,
1
)::float8 AS "avgTurnaroundHours"
FROM freight.warehouse_inventory
WHERE deleted_at IS NULL`,
);
const byHour: Array<{ hour: string; count: number }> = await this.dataSource.query(
`WITH hours AS (
SELECT gs AS h
FROM generate_series(
date_trunc('hour', now()) - interval '23 hours',
date_trunc('hour', now()),
interval '1 hour'
) gs
)
SELECT to_char(hours.h, 'HH24:00') AS hour,
COALESCE(g.cnt, 0)::int AS count
FROM hours
LEFT JOIN (
SELECT date_trunc('hour', gate_cleared_at) AS ph, count(*) AS cnt
FROM freight.warehouse_inventory
WHERE deleted_at IS NULL AND gate_cleared_at IS NOT NULL
GROUP BY 1
) g ON g.ph = hours.h
ORDER BY hours.h`,
);
return {
clearedToday: scalar?.clearedToday ?? 0,
avgTurnaroundHours: scalar?.avgTurnaroundHours ?? null,
byHour,
};
}
/**
* Received-vs-dispatched throughput as a server-side time series. Buckets by
* date_trunc over the last N periods (8 weeks / 12 months / 5 years) with a

View File

@@ -0,0 +1,90 @@
import { Card, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { DoorOpen } from 'lucide-react';
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { useWarehouseGateStats } from '@/hooks/useWarehouses';
/**
* Gate / dock throughput: items cleared through the gate today, the average
* arrival→gate-clearance turnaround, and clearances per hour over the last 24h.
*/
export function GateThroughputCard() {
const { data, isLoading } = useWarehouseGateStats();
const byHour = data?.byHour ?? [];
const hasActivity = byHour.some((h) => h.count > 0);
return (
<Card withBorder radius="lg" padding="lg" h="100%">
<Group gap="sm" mb="md">
<ThemeIcon size="lg" radius="md" color="indigo" variant="light">
<DoorOpen size={20} />
</ThemeIcon>
<div>
<Text fw={700}>Gate &amp; dock throughput</Text>
<Text size="xs" c="dimmed">
Gate clearances over the last 24 hours
</Text>
</div>
</Group>
{isLoading ? (
<Group justify="center" h={220}>
<Loader />
</Group>
) : (
<Stack gap="md">
<SimpleGrid cols={2} spacing="sm">
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Cleared today
</Text>
<Text fw={800} fz={30} lh={1.1}>
{data?.clearedToday ?? 0}
</Text>
<Text size="xs" c="dimmed">
through the gate
</Text>
</Stack>
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Avg turnaround
</Text>
<Text fw={800} fz={30} lh={1.1}>
{data?.avgTurnaroundHours == null ? '—' : `${data.avgTurnaroundHours} h`}
</Text>
<Text size="xs" c="dimmed">
arrival gate (30d)
</Text>
</Stack>
</SimpleGrid>
{hasActivity ? (
<ResponsiveContainer width="100%" height={170}>
<BarChart data={byHour} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--mantine-color-gray-2)" />
<XAxis dataKey="hour" interval={3} tick={{ fontSize: 11 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
<Tooltip cursor={{ fill: 'var(--mantine-color-gray-1)' }} />
<Bar dataKey="count" name="Cleared" fill="#4c6ef5" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<Group justify="center" align="center" h={170}>
<Text c="dimmed" size="sm">
No gate clearances in the last 24 hours.
</Text>
</Group>
)}
</Stack>
)}
</Card>
);
}

View File

@@ -34,3 +34,4 @@ export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip';
export { AccrualDashboard } from './AccrualDashboard';
export { DwellAgingCard } from './DwellAgingCard';
export { CycleTimeCard } from './CycleTimeCard';
export { GateThroughputCard } from './GateThroughputCard';

View File

@@ -494,6 +494,7 @@ export const URL_CONSTANTS = {
`/warehouse-inventory/throughput?granularity=${granularity}`,
DWELL_STATS: "/warehouse-inventory/dwell-stats",
CYCLE_STATS: "/warehouse-inventory/cycle-stats",
GATE_STATS: "/warehouse-inventory/gate-stats",
ZONE_OCCUPANCY: (yardId?: string) =>
yardId
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`

View File

@@ -141,6 +141,7 @@ export function useZoneOccupancy(yardId?: string) {
return useQuery({
queryKey: ['warehouse-zones', 'occupancy', yardId ?? 'all'],
queryFn: () => warehouseService.zoneOccupancy(yardId).then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
});
}
@@ -149,14 +150,19 @@ export function useWarehouseOpsStats() {
return useQuery({
queryKey: ['warehouse-inventory', 'ops-stats'],
queryFn: () => warehouseService.opsStats().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;
/** Server-side received-vs-dispatched throughput time series. */
export function useWarehouseThroughput(granularity: 'week' | 'month' | 'year') {
return useQuery({
queryKey: ['warehouse-inventory', 'throughput', granularity],
queryFn: () => warehouseService.throughput(granularity).then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
});
}
@@ -165,6 +171,7 @@ export function useWarehouseDwellStats() {
return useQuery({
queryKey: ['warehouse-inventory', 'dwell-stats'],
queryFn: () => warehouseService.dwellStats().then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
});
}
@@ -173,6 +180,16 @@ export function useWarehouseCycleStats() {
return useQuery({
queryKey: ['warehouse-inventory', 'cycle-stats'],
queryFn: () => warehouseService.cycleStats().then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
});
}
/** Gate / dock throughput (cleared today, turnaround, hourly clearances). */
export function useWarehouseGateStats() {
return useQuery({
queryKey: ['warehouse-inventory', 'gate-stats'],
queryFn: () => warehouseService.gateStats().then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
});
}
@@ -181,6 +198,7 @@ export function useOnTimeDispatch() {
return useQuery({
queryKey: ['warehouse-fees', 'on-time-dispatch'],
queryFn: () => warehouseService.onTimeDispatch().then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
});
}
@@ -189,6 +207,7 @@ export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') {
return useQuery({
queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'],
queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
});
}
@@ -437,6 +456,7 @@ export function useWarehouseDashboard() {
return useQuery({
queryKey: ['warehouses', 'dashboard'],
queryFn: () => warehouseService.dashboard().then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
});
}

View File

@@ -1,5 +1,5 @@
import { useNavigate } from 'react-router-dom';
import { Card, Center, Divider, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { Badge, Card, Center, Divider, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import {
ClipboardCheck,
ClipboardList,
@@ -20,6 +20,7 @@ import {
AccrualDashboard,
CycleTimeCard,
DwellAgingCard,
GateThroughputCard,
WarehouseDashboardCharts,
WarehouseOpsKpiStrip,
ZoneOccupancyHeatmap,
@@ -71,6 +72,26 @@ export default function WarehouseDashboardPage() {
<PageHeader
title="Warehouse Dashboard"
subtitle="Live overview of warehouse capacity and inventory lifecycle."
action={
<Badge
color="edr-green"
variant="light"
size="lg"
leftSection={
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: 'var(--mantine-color-edr-green-6)',
}}
/>
}
>
Live · updates every 60s
</Badge>
}
/>
{isLoading ? (
@@ -129,9 +150,10 @@ export default function WarehouseDashboardPage() {
<Stack gap="sm">
<SectionTitle>Performance</SectionTitle>
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<SimpleGrid cols={{ base: 1, lg: 2, xl: 3 }} spacing="md">
<DwellAgingCard />
<CycleTimeCard />
<GateThroughputCard />
</SimpleGrid>
</Stack>

View File

@@ -10,6 +10,7 @@ import type {
WarehouseDwellStats,
WarehouseCycleStats,
WarehouseOnTimeStats,
WarehouseGateStats,
AccrualDashboardRow,
AllocationCriteria,
AllocationPreviewResult,
@@ -406,6 +407,8 @@ export const warehouseService = {
apiClient.get<WarehouseDwellStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DWELL_STATS),
cycleStats: () =>
apiClient.get<WarehouseCycleStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.CYCLE_STATS),
gateStats: () =>
apiClient.get<WarehouseGateStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.GATE_STATS),
autoUnloadArrived: () =>
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
autoLoadReady: () =>

View File

@@ -1149,6 +1149,13 @@ export interface WarehouseOnTimeStats {
onTimePct: number | null;
}
/** Gate / dock throughput: cleared today, turnaround, and hourly clearances. */
export interface WarehouseGateStats {
clearedToday: number;
avgTurnaroundHours: number | null;
byHour: Array<{ hour: string; count: number }>;
}
export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
/** One item's live fee accrual for the accrual dashboard. */