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:
Hagernesh
2026-07-15 11:07:45 +00:00
parent ea2d912773
commit 2e22b72e27
8 changed files with 142 additions and 119 deletions

View File

@@ -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}>