From fc4aadbc5733d3f67dc2d28bc435a339283b7eec Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 15 Jul 2026 13:51:23 +0000 Subject: [PATCH] =?UTF-8?q?feat(warehouse):=20P1=20dashboard=20analytics?= =?UTF-8?q?=20=E2=80=94=20dwell/aging,=20cycle-time=20+=20on-time,=20live?= =?UTF-8?q?=20deltas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the operational-performance layer to the warehouse cockpit: - Dwell time & aging: dwellStats() (avg + 0-3/4-7/8-14/15+ buckets over in-warehouse items) → DwellAgingCard histogram. - Cycle time & on-time: cycleStats() (arrived→ready→loaded→dispatched stage averages + dock-to-dispatch) and onTimeDispatchStats() (share of items that left before their storage free-days expired, resolved via the fee engine's own rule matching) → CycleTimeCard. - Live tiles: opsStats() now returns receivedYesterday; KpiStrip renders an optional ▲/▼ delta, and the ops strip shows received-today vs yesterday. New endpoints: GET /warehouse-inventory/{dwell-stats,cycle-stats} and /warehouse-fees/on-time-dispatch (all guarded). New "Performance" section on WarehouseDashboardPage. On-time reads N/A when there is no sample / no active storage rule. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/warehouse-fee.service.ts | 74 ++++++++++++ .../warehouse-inventory.controller.ts | 14 +++ .../warehouses/warehouse-inventory.service.ts | 101 ++++++++++++++++ .../warehouses/warehouse-rules.controller.ts | 7 ++ .../src/components/page/KpiStrip.tsx | 39 ++++-- .../components/warehouses/CycleTimeCard.tsx | 111 ++++++++++++++++++ .../components/warehouses/DwellAgingCard.tsx | 92 +++++++++++++++ .../warehouses/WarehouseOpsKpiStrip.tsx | 4 + .../src/components/warehouses/index.ts | 2 + .../src/components/warehouses/options.ts | 8 ++ .../backoffice/src/constants/URLS.ts | 3 + .../backoffice/src/hooks/useWarehouses.ts | 24 ++++ .../warehouses/WarehouseDashboardPage.tsx | 10 ++ .../src/services/warehouse.service.ts | 9 ++ .../backoffice/src/types/warehouse.ts | 22 ++++ 15 files changed, 510 insertions(+), 10 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/CycleTimeCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/DwellAgingCard.tsx diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index 92aa36b85..3b61d7ff0 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -330,6 +330,80 @@ export class WarehouseFeeService { return best; } + /** + * On-time dispatch rate: the share of items dispatched in the last N days that + * LEFT before their storage free-days expired — CEIL((dispatched−arrived)/day) + * <= freeDays, with freeDays resolved by the same rule matching the fee engine + * uses (bestRule over active STORAGE_FEE rules). onTimePct is null when there + * is nothing to measure (e.g. no dispatched items / no storage rules). + */ + async onTimeDispatchStats( + windowDays = 90, + ): Promise<{ sampleSize: number; onTimeCount: number; onTimePct: number | null }> { + const storageRules = ( + await this.feeRuleRepository.findAll({ where: { isActive: true } }) + ).filter((r) => r.ruleType === 'STORAGE_FEE'); + + // Batched attribute pull mirroring loadItem's scope joins (multi-row) — only + // the fields bestRule/matchScore reads, plus the two clock timestamps. + const rows: Array< + ItemAttributes & { arrivedAt: string; dispatchedAt: string } + > = await this.dataSource.query( + `SELECT inv.arrived_at AS "arrivedAt", + inv.dispatched_at AS "dispatchedAt", + inv.warehouse_id AS "warehouseId", + inv.yard_id AS "yardId", + inv.zone_id AS "zoneId", + w.facility_id AS "facilityId", + b.freight_type AS "freightType", + b.trade_direction AS "tradeDirection", + COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode", + COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode", + NULL AS "vehicleType" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id + LEFT JOIN freight.bookings b ON b.id = inv.booking_id + LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id + LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id + LEFT JOIN freight.cargo_types booking_cgt ON booking_cgt.id = b.cargo_type_id + LEFT JOIN freight.containers ct ON ct.id = inv.container_id + LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id + LEFT JOIN LATERAL ( + SELECT bc.container_type_id + FROM freight.booking_container bc + WHERE bc.booking_id = inv.booking_id + AND bc.deleted_at IS NULL + AND bc.container_type_id IS NOT NULL + ORDER BY bc.created_at ASC + LIMIT 1 + ) booking_container_type ON true + LEFT JOIN freight.container_types booking_ctt ON booking_ctt.id = booking_container_type.container_type_id + WHERE inv.deleted_at IS NULL + AND inv.arrived_at IS NOT NULL + AND inv.dispatched_at IS NOT NULL + AND inv.dispatched_at > now() - ($1 || ' days')::interval`, + [windowDays], + ); + + let onTimeCount = 0; + for (const row of rows) { + const freeDays = this.bestRule(storageRules, row)?.freeDays ?? 0; + const elapsed = Math.max( + 0, + Math.ceil( + (new Date(row.dispatchedAt).getTime() - new Date(row.arrivedAt).getTime()) / MS_PER_DAY, + ), + ); + if (elapsed <= freeDays) onTimeCount += 1; + } + const sampleSize = rows.length; + return { + sampleSize, + onTimeCount, + onTimePct: sampleSize ? Math.round((onTimeCount / sampleSize) * 100) : null, + }; + } + private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' { return currency === 'ETB' ? 'ETB' : 'USD'; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index e9e8d95db..f64fddb3a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -81,6 +81,20 @@ export class WarehouseInventoryController { return this.inventoryService.throughput(g); } + @Get('dwell-stats') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) + @ApiOperation({ summary: 'Dwell time of in-warehouse items: average + aging buckets' }) + dwellStats() { + return this.inventoryService.dwellStats(); + } + + @Get('cycle-stats') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) + @ApiOperation({ summary: 'Average stage cycle times over recently dispatched items' }) + cycleStats() { + return this.inventoryService.cycleStats(); + } + @Post('auto-unload-arrived') @BookingStaff(FREIGHT_PERMS.warehouseInventory.unload) @ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' }) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 793372d84..9c269270a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -406,12 +406,14 @@ export class WarehouseInventoryService { */ async opsStats(): Promise<{ receivedToday: number; + receivedYesterday: number; pendingInspection: number; trucksOnSite: number; itemsAging: number; }> { const [row]: Array<{ receivedToday: number; + receivedYesterday: number; pendingInspection: number; trucksOnSite: number; itemsAging: number; @@ -419,6 +421,8 @@ export class WarehouseInventoryService { `SELECT (SELECT count(*)::int FROM freight.warehouse_inventory WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE) AS "receivedToday", + (SELECT count(*)::int FROM freight.warehouse_inventory + WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE - 1) AS "receivedYesterday", (SELECT count(*)::int FROM freight.warehouse_inventory WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection", (SELECT count(*)::int FROM freight.customer_truck_assignments @@ -430,12 +434,109 @@ export class WarehouseInventoryService { ); return { receivedToday: row?.receivedToday ?? 0, + receivedYesterday: row?.receivedYesterday ?? 0, pendingInspection: row?.pendingInspection ?? 0, trucksOnSite: row?.trucksOnSite ?? 0, itemsAging: row?.itemsAging ?? 0, }; } + /** In-warehouse statuses used by the dwell / aging metrics. */ + private readonly IN_WAREHOUSE_STATUSES = [ + 'RECEIVED', + 'UNLOADED', + 'STORED', + 'RESERVED', + 'READY_FOR_LOADING', + 'READY_FOR_PICKUP', + ]; + + /** + * Dwell time of items still in the warehouse: average days held plus a count + * per aging bucket (0–3 / 4–7 / 8–14 / 15+). Clock starts at arrival (falling + * back to created_at). Powers the dwell / aging histogram. + */ + async dwellStats(): Promise<{ + avgDwellDays: number; + inWarehouseCount: number; + buckets: Array<{ key: string; label: string; count: number }>; + }> { + const [row]: Array<{ + avgDwellDays: number | null; + inWarehouseCount: number; + b0: number; + b1: number; + b2: number; + b3: number; + }> = await this.dataSource.query( + `WITH held AS ( + SELECT EXTRACT(EPOCH FROM (now() - COALESCE(arrived_at, created_at))) / 86400.0 AS age_days + FROM freight.warehouse_inventory + WHERE deleted_at IS NULL + AND status = ANY($1) + ) + SELECT COALESCE(round(avg(age_days)::numeric, 1), 0)::float8 AS "avgDwellDays", + count(*)::int AS "inWarehouseCount", + count(*) FILTER (WHERE age_days < 4)::int AS b0, + count(*) FILTER (WHERE age_days >= 4 AND age_days < 8)::int AS b1, + count(*) FILTER (WHERE age_days >= 8 AND age_days < 15)::int AS b2, + count(*) FILTER (WHERE age_days >= 15)::int AS b3 + FROM held`, + [this.IN_WAREHOUSE_STATUSES], + ); + return { + avgDwellDays: row?.avgDwellDays ?? 0, + inWarehouseCount: row?.inWarehouseCount ?? 0, + buckets: [ + { key: '0-3', label: '0–3 days', count: row?.b0 ?? 0 }, + { key: '4-7', label: '4–7 days', count: row?.b1 ?? 0 }, + { key: '8-14', label: '8–14 days', count: row?.b2 ?? 0 }, + { key: '15+', label: '15+ days', count: row?.b3 ?? 0 }, + ], + }; + } + + /** + * Average stage cycle times over items dispatched in the last 90 days: + * arrived→ready, ready→loaded, loaded→dispatched, and the total + * arrived→dispatched (dock-to-dispatch). Days, to one decimal. + */ + async cycleStats(): Promise<{ + sampleSize: number; + avgDockToDispatchDays: number; + stages: Array<{ key: string; label: string; avgDays: number }>; + }> { + const gapDays = (from: string, to: string) => + `round((avg(EXTRACT(EPOCH FROM (${to} - ${from})) / 86400.0) FILTER (WHERE ${from} IS NOT NULL AND ${to} IS NOT NULL))::numeric, 1)::float8`; + const [row]: Array<{ + sampleSize: number; + total: number | null; + arrivedReady: number | null; + readyLoaded: number | null; + loadedDispatched: number | null; + }> = await this.dataSource.query( + `SELECT count(*)::int AS "sampleSize", + ${gapDays('arrived_at', 'dispatched_at')} AS "total", + ${gapDays('arrived_at', 'ready_for_loading_at')} AS "arrivedReady", + ${gapDays('ready_for_loading_at', 'loaded_at')} AS "readyLoaded", + ${gapDays('loaded_at', 'dispatched_at')} AS "loadedDispatched" + FROM freight.warehouse_inventory + WHERE deleted_at IS NULL + AND arrived_at IS NOT NULL + AND dispatched_at IS NOT NULL + AND dispatched_at > now() - interval '90 days'`, + ); + return { + sampleSize: row?.sampleSize ?? 0, + avgDockToDispatchDays: row?.total ?? 0, + stages: [ + { key: 'arrived-ready', label: 'Arrived → Ready', avgDays: row?.arrivedReady ?? 0 }, + { key: 'ready-loaded', label: 'Ready → Loaded', avgDays: row?.readyLoaded ?? 0 }, + { key: 'loaded-dispatched', label: 'Loaded → Dispatched', avgDays: row?.loadedDispatched ?? 0 }, + ], + }; + } + /** * 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 diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts index 7a5c53d38..3a60de4ea 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts @@ -96,6 +96,13 @@ export class WarehouseRulesController { return this.feeService.accrualDashboard(billingCurrency); } + @Get('warehouse-fees/on-time-dispatch') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view) + @ApiOperation({ summary: 'On-time dispatch rate — items that left before storage free-days expired' }) + onTimeDispatch() { + return this.feeService.onTimeDispatchStats(); + } + @Post('warehouse-fees/accrual/:inventoryId/acknowledge') @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update) @ApiOperation({ summary: 'Acknowledge / snooze an item fee-accrual alert' }) diff --git a/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx b/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx index 6c8167a33..4dfd39b0c 100644 --- a/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx +++ b/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx @@ -17,6 +17,11 @@ export interface KpiItem { * into semantic tints. */ color?: string; + /** + * Optional change vs a prior period, rendered as a ▲/▼ chip next to the value + * (green up, red down, muted zero). E.g. today's count minus yesterday's. + */ + delta?: number; } export interface KpiStripProps { @@ -67,16 +72,30 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) { {loading ? ( ) : ( - - {item.value} - +
+ + {item.value} + + {item.delta != null && item.delta !== 0 ? ( + 0 ? "edr-green" : "red"} + style={{ whiteSpace: "nowrap" }} + > + {item.delta > 0 ? "▲" : "▼"} + {Math.abs(item.delta)} + + ) : null} +
)} {item.label} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/CycleTimeCard.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/CycleTimeCard.tsx new file mode 100644 index 000000000..3127f3bba --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/CycleTimeCard.tsx @@ -0,0 +1,111 @@ +import { Card, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core'; +import { Gauge } from 'lucide-react'; +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import { useOnTimeDispatch, useWarehouseCycleStats } from '@/hooks/useWarehouses'; +import { formatDays } from './options'; + +function onTimeColor(pct: number | null | undefined): string { + if (pct == null) return 'edr-text'; + if (pct >= 80) return 'teal'; + if (pct >= 50) return 'orange'; + return 'red'; +} + +/** + * Warehouse performance: on-time dispatch rate (items that left before their + * storage free-days expired), average dock-to-dispatch, and the per-stage + * cycle times that make it up. + */ +export function CycleTimeCard() { + const { data: cycle, isLoading: cycleLoading } = useWarehouseCycleStats(); + const { data: onTime, isLoading: onTimeLoading } = useOnTimeDispatch(); + const isLoading = cycleLoading || onTimeLoading; + + const hasStages = (cycle?.sampleSize ?? 0) > 0; + const stages = cycle?.stages ?? []; + + return ( + + + + + +
+ Cycle time & on-time + + Dispatch performance over the last 90 days + +
+
+ + {isLoading ? ( + + + + ) : ( + + + + + On-time dispatch + + + {onTime?.onTimePct == null ? 'N/A' : `${onTime.onTimePct}%`} + + + {onTime?.onTimePct == null + ? 'No storage rule / sample' + : `${onTime.onTimeCount}/${onTime.sampleSize} left before free-days`} + + + + + Dock → dispatch + + + {hasStages ? formatDays(cycle?.avgDockToDispatchDays) : '—'} + + + avg over {cycle?.sampleSize ?? 0} dispatched + + + + + {hasStages ? ( + + + + + + [`${value} days`, 'Avg'] as [string, string]} + /> + + + + ) : ( + + + Not enough dispatched items yet to chart stage times. + + + )} + + )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/DwellAgingCard.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/DwellAgingCard.tsx new file mode 100644 index 000000000..a925f4a15 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/DwellAgingCard.tsx @@ -0,0 +1,92 @@ +import { Card, Group, Loader, Stack, Text, ThemeIcon } from '@mantine/core'; +import { Hourglass } from 'lucide-react'; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import { useWarehouseDwellStats } from '@/hooks/useWarehouses'; +import { formatDays } from './options'; + +/** Green → amber → red as items age. Aligned with the zone-occupancy heat scale. */ +const BUCKET_COLORS = ['#12b886', '#40c057', '#f08c00', '#fa5252']; + +/** + * Dwell time of items still in the warehouse: the average, plus how the current + * stock is spread across aging buckets (0–3 / 4–7 / 8–14 / 15+ days). + */ +export function DwellAgingCard() { + const { data, isLoading } = useWarehouseDwellStats(); + const buckets = data?.buckets ?? []; + const hasItems = (data?.inWarehouseCount ?? 0) > 0; + + return ( + + + + + +
+ Dwell time & aging + + How long current stock has been held + +
+
+ + {isLoading ? ( + + + + ) : ( + + + + Avg dwell + + + {hasItems ? formatDays(data?.avgDwellDays) : '—'} + + + {data?.inWarehouseCount ?? 0} item{(data?.inWarehouseCount ?? 0) === 1 ? '' : 's'} in warehouse + + + +
+ {hasItems ? ( + + + + + + + + {buckets.map((b, i) => ( + + ))} + + + + ) : ( + + + No items currently in the warehouse. + + + )} +
+
+ )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseOpsKpiStrip.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseOpsKpiStrip.tsx index cf8e34343..88a60bbb4 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseOpsKpiStrip.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseOpsKpiStrip.tsx @@ -19,6 +19,10 @@ export function WarehouseOpsKpiStrip() { value: data?.receivedToday ?? 0, icon: PackageCheck, color: "edr-green", + // Live signal: change vs yesterday's received count. + delta: + data != null ? data.receivedToday - data.receivedYesterday : undefined, + hint: "vs yesterday", }, { label: "Pending inspection", diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts index 7b3504480..78b9ef1f6 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts @@ -32,3 +32,5 @@ export { FeePreviewModal } from './FeePreviewModal'; export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap'; export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip'; export { AccrualDashboard } from './AccrualDashboard'; +export { DwellAgingCard } from './DwellAgingCard'; +export { CycleTimeCard } from './CycleTimeCard'; diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts index 3a71ea2a5..d124a3325 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts @@ -35,6 +35,14 @@ export const formatCapacity = (current: number, capacity: number | null | undefi return `${cur} / ${formatNumber(capacity)}`; }; +/** A day count as a short, human duration: "0.2d" / "3.5 days" / "—". */ +export const formatDays = (value: number | null | undefined) => { + if (value === null || value === undefined || Number.isNaN(Number(value))) return '—'; + const num = Number(value); + const rounded = Math.round(num * 10) / 10; + return `${rounded} ${rounded === 1 ? 'day' : 'days'}`; +}; + export const formatDate = (value: string | null | undefined) => { if (!value) return '—'; const date = new Date(value); diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index b9a78e843..5b718dad2 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -492,6 +492,8 @@ export const URL_CONSTANTS = { OPS_STATS: "/warehouse-inventory/ops-stats", THROUGHPUT: (granularity: 'week' | 'month' | 'year') => `/warehouse-inventory/throughput?granularity=${granularity}`, + DWELL_STATS: "/warehouse-inventory/dwell-stats", + CYCLE_STATS: "/warehouse-inventory/cycle-stats", ZONE_OCCUPANCY: (yardId?: string) => yardId ? `/warehouse-inventory/zone-occupancy?yardId=${yardId}` @@ -564,6 +566,7 @@ export const URL_CONSTANTS = { FEE_PREVIEW: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-preview`, ACCRUAL_DASHBOARD: "/warehouse-fees/accrual-dashboard", + ON_TIME_DISPATCH: "/warehouse-fees/on-time-dispatch", ACCRUAL_ACK: (inventoryId: string) => `/warehouse-fees/accrual/${inventoryId}/acknowledge`, }, diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts index 8b10bfbd4..a4aac4411 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -160,6 +160,30 @@ export function useWarehouseThroughput(granularity: 'week' | 'month' | 'year') { }); } +/** Dwell time of in-warehouse items (average + aging buckets). */ +export function useWarehouseDwellStats() { + return useQuery({ + queryKey: ['warehouse-inventory', 'dwell-stats'], + queryFn: () => warehouseService.dwellStats().then((r) => r.data), + }); +} + +/** Average stage cycle times over recently dispatched items. */ +export function useWarehouseCycleStats() { + return useQuery({ + queryKey: ['warehouse-inventory', 'cycle-stats'], + queryFn: () => warehouseService.cycleStats().then((r) => r.data), + }); +} + +/** On-time dispatch rate (left before storage free-days expired). */ +export function useOnTimeDispatch() { + return useQuery({ + queryKey: ['warehouse-fees', 'on-time-dispatch'], + queryFn: () => warehouseService.onTimeDispatch().then((r) => r.data), + }); +} + /** Live per-item fee accrual (storage/demurrage) with alerts. */ export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') { return useQuery({ diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx index 8a5ffd08c..f7932f7d6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx @@ -18,6 +18,8 @@ import { import { PageContainer, PageHeader } from '@/components/page'; import { AccrualDashboard, + CycleTimeCard, + DwellAgingCard, WarehouseDashboardCharts, WarehouseOpsKpiStrip, ZoneOccupancyHeatmap, @@ -125,6 +127,14 @@ export default function WarehouseDashboardPage() { + + Performance + + + + + + Zone capacity diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index 4a73ee1d3..fdf259169 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -7,6 +7,9 @@ import type { ZoneOccupancy, WarehouseOpsStats, WarehouseThroughputPoint, + WarehouseDwellStats, + WarehouseCycleStats, + WarehouseOnTimeStats, AccrualDashboardRow, AllocationCriteria, AllocationPreviewResult, @@ -399,6 +402,10 @@ export const warehouseService = { apiClient.get( URL_CONSTANTS.WAREHOUSE_INVENTORY.THROUGHPUT(granularity), ), + dwellStats: () => + apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.DWELL_STATS), + cycleStats: () => + apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.CYCLE_STATS), autoUnloadArrived: () => apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED), autoLoadReady: () => @@ -455,6 +462,8 @@ export const warehouseService = { apiClient.get(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, { params: cleanParams({ billingCurrency }), }), + onTimeDispatch: () => + apiClient.get(URL_CONSTANTS.WAREHOUSE_RULES.ON_TIME_DISPATCH), acknowledgeAccrual: (inventoryId: string, body: { snoozeDays?: number; note?: string } = {}) => apiClient.post(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_ACK(inventoryId), body), unacknowledgeAccrual: (inventoryId: string) => diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index eb7d53739..f85e66b62 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -1115,6 +1115,7 @@ export interface ZoneOccupancy { /** At-a-glance warehouse ops counters for the KPI strip. */ export interface WarehouseOpsStats { receivedToday: number; + receivedYesterday: number; pendingInspection: number; trucksOnSite: number; itemsAging: number; @@ -1127,6 +1128,27 @@ export interface WarehouseThroughputPoint { dispatched: number; } +/** Dwell time of in-warehouse items: average days + aging-bucket counts. */ +export interface WarehouseDwellStats { + avgDwellDays: number; + inWarehouseCount: number; + buckets: Array<{ key: string; label: string; count: number }>; +} + +/** Average stage cycle times over recently dispatched items. */ +export interface WarehouseCycleStats { + sampleSize: number; + avgDockToDispatchDays: number; + stages: Array<{ key: string; label: string; avgDays: number }>; +} + +/** On-time dispatch rate (items that left before storage free-days expired). */ +export interface WarehouseOnTimeStats { + sampleSize: number; + onTimeCount: number; + onTimePct: number | null; +} + export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING'; /** One item's live fee accrual for the accrual dashboard. */