mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 14:20:58 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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' })
|
||||
|
||||
@@ -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<Array<{ periodStart: string; received: number; dispatched: number }>> {
|
||||
// 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
|
||||
|
||||
@@ -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<Granularity>('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 (
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
<Stack gap="md">
|
||||
{/* Time-filtered throughput */}
|
||||
<Card withBorder radius="lg" padding="lg" style={{ gridColumn: '1 / -1' }}>
|
||||
<Card withBorder radius="lg" padding="lg">
|
||||
<Group justify="space-between" mb="md" wrap="wrap">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: ORANGE, color: '#fff' }}>
|
||||
@@ -133,103 +137,10 @@ export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps
|
||||
<EmptyChart />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Status distribution donut */}
|
||||
<Card withBorder radius="lg" padding="lg">
|
||||
<Group gap="sm" mb="md">
|
||||
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: ORANGE, color: '#fff' }}>
|
||||
<PieChartIcon size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Lifecycle Distribution</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Share of inventory across statuses
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{hasStatus ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={statusData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={55}
|
||||
outerRadius={95}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{statusData.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend verticalAlign="bottom" height={36} iconType="circle" />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyChart />
|
||||
)}
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Group justify="center" align="center" h={280}>
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 (
|
||||
<Text fw={700} fz="sm" tt="uppercase" c="edr-muted" style={{ letterSpacing: 0.4 }}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
interface Metric {
|
||||
key: keyof WarehouseDashboard;
|
||||
label: string;
|
||||
@@ -67,7 +80,16 @@ export default function WarehouseDashboardPage() {
|
||||
<Text c="red">Failed to load warehouse dashboard.</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<>
|
||||
<Stack gap="xl">
|
||||
{/* Needs attention — live ops counters (received today, pending
|
||||
inspection, trucks on-site, items aging > 7 days). */}
|
||||
<Stack gap="sm">
|
||||
<SectionTitle>Needs attention</SectionTitle>
|
||||
<WarehouseOpsKpiStrip />
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
|
||||
{METRICS.map((metric) => (
|
||||
<Card
|
||||
@@ -98,8 +120,21 @@ export default function WarehouseDashboardPage() {
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<WarehouseDashboardCharts data={data} />
|
||||
</>
|
||||
<Stack gap="sm">
|
||||
<SectionTitle>Flow</SectionTitle>
|
||||
<WarehouseDashboardCharts data={data} />
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<SectionTitle>Zone capacity</SectionTitle>
|
||||
<ZoneOccupancyHeatmap />
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<SectionTitle>Demurrage & storage exceptions</SectionTitle>
|
||||
<AccrualDashboard />
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
@@ -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<WarehouseOpsStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.OPS_STATS),
|
||||
throughput: (granularity: 'week' | 'month' | 'year') =>
|
||||
apiClient.get<WarehouseThroughputPoint[]>(
|
||||
URL_CONSTANTS.WAREHOUSE_INVENTORY.THROUGHPUT(granularity),
|
||||
),
|
||||
autoUnloadArrived: () =>
|
||||
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
|
||||
autoLoadReady: () =>
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user