mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 15:30:56 +00:00
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:
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -34,3 +34,4 @@ export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip';
|
||||
export { AccrualDashboard } from './AccrualDashboard';
|
||||
export { DwellAgingCard } from './DwellAgingCard';
|
||||
export { CycleTimeCard } from './CycleTimeCard';
|
||||
export { GateThroughputCard } from './GateThroughputCard';
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user