From 2e22b72e2786d277b651fa4d493d3fcdfb9bf387 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 15 Jul 2026 11:07:45 +0000 Subject: [PATCH] feat(warehouse): assemble dashboard cockpit + server-side throughput series - WarehouseDashboardPage now composes the ops KPI strip, lifecycle cards, flow charts, zone-occupancy heatmap and demurrage/accrual exceptions into one control-tower view; drop the redundant lifecycle donut. - New GET /warehouse-inventory/throughput (date_trunc time series) replaces the client-side buildTrend that downloaded the entire inventory list to bucket it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouse-inventory.controller.ts | 8 + .../warehouses/warehouse-inventory.service.ts | 47 ++++++ .../warehouses/WarehouseDashboardCharts.tsx | 139 ++++-------------- .../backoffice/src/constants/URLS.ts | 2 + .../backoffice/src/hooks/useWarehouses.ts | 8 + .../warehouses/WarehouseDashboardPage.tsx | 45 +++++- .../src/services/warehouse.service.ts | 5 + .../backoffice/src/types/warehouse.ts | 7 + 8 files changed, 142 insertions(+), 119 deletions(-) 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 8b14e6cdc..e9e8d95db 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 @@ -73,6 +73,14 @@ export class WarehouseInventoryController { return this.inventoryService.zoneOccupancy(yardId); } + @Get('throughput') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) + @ApiOperation({ summary: 'Received-vs-dispatched throughput time series (week/month/year)' }) + throughput(@Query('granularity') granularity?: string) { + const g = granularity === 'week' || granularity === 'year' ? granularity : 'month'; + return this.inventoryService.throughput(g); + } + @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 9a42bc895..793372d84 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 @@ -436,6 +436,53 @@ export class WarehouseInventoryService { }; } + /** + * 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 + * generate_series so empty periods still return a zero row — replaces the + * client-side approach that downloaded the whole inventory to bucket it. + */ + async throughput( + granularity: 'week' | 'month' | 'year' = 'month', + ): Promise> { + // Whitelist the unit — it is interpolated into date_trunc / interval literals. + const unit: 'week' | 'month' | 'year' = ['week', 'month', 'year'].includes(granularity) + ? granularity + : 'month'; + const back = unit === 'week' ? 7 : unit === 'month' ? 11 : 4; + + const rows: Array<{ periodStart: string; received: number; dispatched: number }> = + await this.dataSource.query( + `WITH periods AS ( + SELECT gs AS period_start + FROM generate_series( + date_trunc('${unit}', now()) - ($1 || ' ${unit}')::interval, + date_trunc('${unit}', now()), + '1 ${unit}'::interval + ) gs + ) + SELECT p.period_start AS "periodStart", + COALESCE(r.cnt, 0)::int AS received, + COALESCE(d.cnt, 0)::int AS dispatched + FROM periods p + LEFT JOIN ( + SELECT date_trunc('${unit}', arrived_at) AS ps, count(*) AS cnt + FROM freight.warehouse_inventory + WHERE deleted_at IS NULL AND arrived_at IS NOT NULL + GROUP BY 1 + ) r ON r.ps = p.period_start + LEFT JOIN ( + SELECT date_trunc('${unit}', dispatched_at) AS ps, count(*) AS cnt + FROM freight.warehouse_inventory + WHERE deleted_at IS NULL AND dispatched_at IS NOT NULL + GROUP BY 1 + ) d ON d.ps = p.period_start + ORDER BY p.period_start`, + [back], + ); + return rows; + } + /** * Live occupancy per zone: rated capacity vs the weight/items currently held * (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx index 993544fe3..71700fcf3 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx @@ -1,24 +1,20 @@ -import { useMemo, useState } from 'react'; -import { Card, Group, SegmentedControl, SimpleGrid, Text, ThemeIcon } from '@mantine/core'; -import { BarChart3, CalendarRange, PieChart as PieChartIcon } from 'lucide-react'; +import { useState } from 'react'; +import { Card, Group, SegmentedControl, Stack, Text, ThemeIcon } from '@mantine/core'; +import { BarChart3, CalendarRange } from 'lucide-react'; import { Bar, BarChart, CartesianGrid, Cell, Legend, - Pie, - PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, } from 'recharts'; -import { useQuery } from '@tanstack/react-query'; - -import { api } from '@/services/api'; -import type { WarehouseDashboard, WarehouseInventoryItem } from '@/types/warehouse'; +import { useWarehouseThroughput } from '@/hooks/useWarehouses'; +import type { WarehouseDashboard } from '@/types/warehouse'; interface WarehouseDashboardChartsProps { data?: WarehouseDashboard; @@ -38,11 +34,25 @@ const STATUS_SERIES = [ type Granularity = 'week' | 'month' | 'year'; +/** Label a period start according to the selected granularity. */ +function formatPeriod(iso: string, granularity: Granularity): string { + const d = new Date(iso); + if (granularity === 'year') return String(d.getFullYear()); + if (granularity === 'week') return d.toLocaleDateString('en', { day: 'numeric', month: 'short' }); + return d.toLocaleDateString('en', { month: 'short' }); +} + export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps) { const [granularity, setGranularity] = useState('month'); - const { data: inventory } = useQuery( - api.warehouses.listInventory.queryOptions({ input: {} }), - ); + // Server-side time series (replaces downloading the whole inventory to bucket). + const { data: series = [] } = useWarehouseThroughput(granularity); + + const trend = series.map((p) => ({ + label: formatPeriod(p.periodStart, granularity), + received: p.received, + dispatched: p.dispatched, + })); + const hasTrend = trend.some((b) => b.received > 0 || b.dispatched > 0); const statusData = STATUS_SERIES.map((s) => ({ name: s.label, @@ -51,16 +61,10 @@ export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps })); const hasStatus = statusData.some((d) => d.value > 0); - const trend = useMemo( - () => buildTrend(inventory ?? [], granularity), - [inventory, granularity], - ); - const hasTrend = trend.some((b) => b.received > 0 || b.dispatched > 0); - return ( - + {/* Time-filtered throughput */} - + @@ -133,103 +137,10 @@ export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps )} - - {/* Status distribution donut */} - - - - - -
- Lifecycle Distribution - - Share of inventory across statuses - -
-
- - {hasStatus ? ( - - - - {statusData.map((entry) => ( - - ))} - - - - - - ) : ( - - )} -
-
+ ); } -interface TrendBucket { - label: string; - received: number; - dispatched: number; -} - -/** Bucket inventory by arrived/dispatched timestamps into recent week/month/year periods. */ -function buildTrend(items: WarehouseInventoryItem[], granularity: Granularity): TrendBucket[] { - const now = new Date(); - const buckets: { label: string; start: Date; end: Date }[] = []; - - if (granularity === 'week') { - for (let i = 7; i >= 0; i--) { - const end = new Date(now); - end.setDate(now.getDate() - i * 7); - const start = new Date(end); - start.setDate(end.getDate() - 7); - buckets.push({ label: `W${8 - i}`, start, end }); - } - } else if (granularity === 'month') { - for (let i = 11; i >= 0; i--) { - const start = new Date(now.getFullYear(), now.getMonth() - i, 1); - const end = new Date(now.getFullYear(), now.getMonth() - i + 1, 1); - buckets.push({ - label: start.toLocaleString('en', { month: 'short' }), - start, - end, - }); - } - } else { - for (let i = 4; i >= 0; i--) { - const year = now.getFullYear() - i; - buckets.push({ - label: String(year), - start: new Date(year, 0, 1), - end: new Date(year + 1, 0, 1), - }); - } - } - - const inRange = (iso: string | null | undefined, start: Date, end: Date) => { - if (!iso) return false; - const t = new Date(iso).getTime(); - return t >= start.getTime() && t < end.getTime(); - }; - - return buckets.map((b) => ({ - label: b.label, - received: items.filter((it) => inRange(it.arrivedAt, b.start, b.end)).length, - dispatched: items.filter((it) => inRange(it.dispatchedAt, b.start, b.end)).length, - })); -} - function EmptyChart() { return ( diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 618d0ba30..37564b0fe 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -487,6 +487,8 @@ export const URL_CONSTANTS = { RESERVE: "/warehouse-inventory/reserve", ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue", OPS_STATS: "/warehouse-inventory/ops-stats", + THROUGHPUT: (granularity: 'week' | 'month' | 'year') => + `/warehouse-inventory/throughput?granularity=${granularity}`, ZONE_OCCUPANCY: (yardId?: string) => yardId ? `/warehouse-inventory/zone-occupancy?yardId=${yardId}` diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts index 56ef4876b..8b10bfbd4 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -152,6 +152,14 @@ export function useWarehouseOpsStats() { }); } +/** 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), + }); +} + /** 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 8524a7e68..8a5ffd08c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx @@ -1,5 +1,5 @@ import { useNavigate } from 'react-router-dom'; -import { Card, Center, Group, Loader, SimpleGrid, Text, ThemeIcon } from '@mantine/core'; +import { Card, Center, Divider, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core'; import { ClipboardCheck, ClipboardList, @@ -16,10 +16,23 @@ import { } from 'lucide-react'; import { PageContainer, PageHeader } from '@/components/page'; -import { WarehouseDashboardCharts } from '@/components/warehouses'; +import { + AccrualDashboard, + WarehouseDashboardCharts, + WarehouseOpsKpiStrip, + ZoneOccupancyHeatmap, +} from '@/components/warehouses'; import { useWarehouseDashboard } from '@/hooks/useWarehouses'; import type { WarehouseDashboard } from '@/types/warehouse'; +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + interface Metric { key: keyof WarehouseDashboard; label: string; @@ -67,7 +80,16 @@ export default function WarehouseDashboardPage() { Failed to load warehouse dashboard. ) : ( - <> + + {/* Needs attention — live ops counters (received today, pending + inspection, trucks on-site, items aging > 7 days). */} + + Needs attention + + + + + {METRICS.map((metric) => ( - - + + Flow + + + + + Zone capacity + + + + + Demurrage & storage exceptions + + + )} ); 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 53ba263a5..4a73ee1d3 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -6,6 +6,7 @@ import { URL_CONSTANTS } from '@/constants/URLS'; import type { ZoneOccupancy, WarehouseOpsStats, + WarehouseThroughputPoint, AccrualDashboardRow, AllocationCriteria, AllocationPreviewResult, @@ -394,6 +395,10 @@ export const warehouseService = { ), opsStats: () => apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.OPS_STATS), + throughput: (granularity: 'week' | 'month' | 'year') => + apiClient.get( + URL_CONSTANTS.WAREHOUSE_INVENTORY.THROUGHPUT(granularity), + ), autoUnloadArrived: () => apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED), autoLoadReady: () => diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index 348646dbb..eb7d53739 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -1120,6 +1120,13 @@ export interface WarehouseOpsStats { itemsAging: number; } +/** One bucket of the received-vs-dispatched throughput time series. */ +export interface WarehouseThroughputPoint { + periodStart: string; + received: number; + dispatched: number; +} + export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING'; /** One item's live fee accrual for the accrual dashboard. */