mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 00:10:57 +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:
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user