mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 14:15:44 +00:00
- 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>
153 lines
5.5 KiB
TypeScript
153 lines
5.5 KiB
TypeScript
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,
|
|
ResponsiveContainer,
|
|
Tooltip,
|
|
XAxis,
|
|
YAxis,
|
|
} from 'recharts';
|
|
|
|
import { useWarehouseThroughput } from '@/hooks/useWarehouses';
|
|
import type { WarehouseDashboard } from '@/types/warehouse';
|
|
|
|
interface WarehouseDashboardChartsProps {
|
|
data?: WarehouseDashboard;
|
|
}
|
|
|
|
const ORANGE = '#f08c00';
|
|
const GREEN = '#22c55e'; // green from bookings
|
|
|
|
/** Inventory lifecycle status series — one distinct color per status (aligned with status badges). */
|
|
const STATUS_SERIES = [
|
|
{ key: 'stored', label: 'Stored', color: '#228be6' }, // blue
|
|
{ key: 'reserved', label: 'Reserved', color: '#ae3ec9' }, // grape
|
|
{ key: 'readyForLoading', label: 'Ready', color: '#f08c00' }, // orange
|
|
{ key: 'loaded', label: 'Loaded', color: '#12b886' }, // teal
|
|
{ key: 'dispatched', label: 'Dispatched', color: GREEN }, // green (bookings)
|
|
] as const;
|
|
|
|
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');
|
|
// 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,
|
|
value: data ? Number(data[s.key as keyof WarehouseDashboard] ?? 0) : 0,
|
|
color: s.color,
|
|
}));
|
|
const hasStatus = statusData.some((d) => d.value > 0);
|
|
|
|
return (
|
|
<Stack gap="md">
|
|
{/* Time-filtered throughput */}
|
|
<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' }}>
|
|
<CalendarRange size={20} />
|
|
</ThemeIcon>
|
|
<div>
|
|
<Text fw={700}>Throughput Over Time</Text>
|
|
<Text size="xs" c="dimmed">
|
|
Received vs dispatched inventory
|
|
</Text>
|
|
</div>
|
|
</Group>
|
|
<SegmentedControl
|
|
value={granularity}
|
|
onChange={(v) => setGranularity(v as Granularity)}
|
|
data={[
|
|
{ label: 'Weekly', value: 'week' },
|
|
{ label: 'Monthly', value: 'month' },
|
|
{ label: 'Yearly', value: 'year' },
|
|
]}
|
|
/>
|
|
</Group>
|
|
|
|
{hasTrend ? (
|
|
<ResponsiveContainer width="100%" height={300}>
|
|
<BarChart data={trend} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--mantine-color-gray-2)" />
|
|
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
|
|
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
|
|
<Tooltip cursor={{ fill: 'var(--mantine-color-gray-1)' }} />
|
|
<Legend iconType="circle" />
|
|
<Bar dataKey="received" name="Received" fill={ORANGE} radius={[6, 6, 0, 0]} />
|
|
<Bar dataKey="dispatched" name="Dispatched" fill={GREEN} radius={[6, 6, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<EmptyChart />
|
|
)}
|
|
</Card>
|
|
|
|
{/* Status distribution bar */}
|
|
<Card withBorder radius="lg" padding="lg">
|
|
<Group gap="sm" mb="md">
|
|
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: GREEN, color: '#fff' }}>
|
|
<BarChart3 size={20} />
|
|
</ThemeIcon>
|
|
<div>
|
|
<Text fw={700}>Inventory by Status</Text>
|
|
<Text size="xs" c="dimmed">
|
|
Items at each lifecycle stage
|
|
</Text>
|
|
</div>
|
|
</Group>
|
|
|
|
{hasStatus ? (
|
|
<ResponsiveContainer width="100%" height={280}>
|
|
<BarChart data={statusData} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--mantine-color-gray-2)" />
|
|
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
|
|
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
|
|
<Tooltip cursor={{ fill: 'var(--mantine-color-gray-1)' }} />
|
|
<Bar dataKey="value" name="Items" radius={[6, 6, 0, 0]}>
|
|
{statusData.map((entry) => (
|
|
<Cell key={entry.name} fill={entry.color} />
|
|
))}
|
|
</Bar>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<EmptyChart />
|
|
)}
|
|
</Card>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
function EmptyChart() {
|
|
return (
|
|
<Group justify="center" align="center" h={280}>
|
|
<Text c="dimmed" size="sm">
|
|
No inventory data to chart yet.
|
|
</Text>
|
|
</Group>
|
|
);
|
|
}
|