mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 07:51:02 +00:00
feat(warehouse): P1 dashboard analytics — dwell/aging, cycle-time + on-time, live deltas
Adds the operational-performance layer to the warehouse cockpit:
- Dwell time & aging: dwellStats() (avg + 0-3/4-7/8-14/15+ buckets over
in-warehouse items) → DwellAgingCard histogram.
- Cycle time & on-time: cycleStats() (arrived→ready→loaded→dispatched stage
averages + dock-to-dispatch) and onTimeDispatchStats() (share of items that
left before their storage free-days expired, resolved via the fee engine's
own rule matching) → CycleTimeCard.
- Live tiles: opsStats() now returns receivedYesterday; KpiStrip renders an
optional ▲/▼ delta, and the ops strip shows received-today vs yesterday.
New endpoints: GET /warehouse-inventory/{dwell-stats,cycle-stats} and
/warehouse-fees/on-time-dispatch (all guarded). New "Performance" section on
WarehouseDashboardPage. On-time reads N/A when there is no sample / no active
storage rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,11 @@ export interface KpiItem {
|
||||
* into semantic tints.
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* Optional change vs a prior period, rendered as a ▲/▼ chip next to the value
|
||||
* (green up, red down, muted zero). E.g. today's count minus yesterday's.
|
||||
*/
|
||||
delta?: number;
|
||||
}
|
||||
|
||||
export interface KpiStripProps {
|
||||
@@ -67,16 +72,30 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
|
||||
{loading ? (
|
||||
<Skeleton height={26} width={72} radius="sm" my={2} />
|
||||
) : (
|
||||
<Text
|
||||
fw={800}
|
||||
fz={24}
|
||||
lh={1.05}
|
||||
c="edr-text"
|
||||
style={{ letterSpacing: "-0.02em" }}
|
||||
truncate
|
||||
>
|
||||
{item.value}
|
||||
</Text>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<Text
|
||||
fw={800}
|
||||
fz={24}
|
||||
lh={1.05}
|
||||
c="edr-text"
|
||||
style={{ letterSpacing: "-0.02em" }}
|
||||
truncate
|
||||
>
|
||||
{item.value}
|
||||
</Text>
|
||||
{item.delta != null && item.delta !== 0 ? (
|
||||
<Text
|
||||
component="span"
|
||||
fz="xs"
|
||||
fw={700}
|
||||
c={item.delta > 0 ? "edr-green" : "red"}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{item.delta > 0 ? "▲" : "▼"}
|
||||
{Math.abs(item.delta)}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<Text size="xs" fw={600} c="edr-muted" truncate>
|
||||
{item.label}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Card, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import { Gauge } from 'lucide-react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { useOnTimeDispatch, useWarehouseCycleStats } from '@/hooks/useWarehouses';
|
||||
import { formatDays } from './options';
|
||||
|
||||
function onTimeColor(pct: number | null | undefined): string {
|
||||
if (pct == null) return 'edr-text';
|
||||
if (pct >= 80) return 'teal';
|
||||
if (pct >= 50) return 'orange';
|
||||
return 'red';
|
||||
}
|
||||
|
||||
/**
|
||||
* Warehouse performance: on-time dispatch rate (items that left before their
|
||||
* storage free-days expired), average dock-to-dispatch, and the per-stage
|
||||
* cycle times that make it up.
|
||||
*/
|
||||
export function CycleTimeCard() {
|
||||
const { data: cycle, isLoading: cycleLoading } = useWarehouseCycleStats();
|
||||
const { data: onTime, isLoading: onTimeLoading } = useOnTimeDispatch();
|
||||
const isLoading = cycleLoading || onTimeLoading;
|
||||
|
||||
const hasStages = (cycle?.sampleSize ?? 0) > 0;
|
||||
const stages = cycle?.stages ?? [];
|
||||
|
||||
return (
|
||||
<Card withBorder radius="lg" padding="lg" h="100%">
|
||||
<Group gap="sm" mb="md">
|
||||
<ThemeIcon size="lg" radius="md" color="teal" variant="light">
|
||||
<Gauge size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Cycle time & on-time</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Dispatch performance over the last 90 days
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" h={220}>
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
On-time dispatch
|
||||
</Text>
|
||||
<Text fw={800} fz={30} lh={1.1} c={onTimeColor(onTime?.onTimePct)}>
|
||||
{onTime?.onTimePct == null ? 'N/A' : `${onTime.onTimePct}%`}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{onTime?.onTimePct == null
|
||||
? 'No storage rule / sample'
|
||||
: `${onTime.onTimeCount}/${onTime.sampleSize} left before free-days`}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
Dock → dispatch
|
||||
</Text>
|
||||
<Text fw={800} fz={30} lh={1.1}>
|
||||
{hasStages ? formatDays(cycle?.avgDockToDispatchDays) : '—'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
avg over {cycle?.sampleSize ?? 0} dispatched
|
||||
</Text>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
|
||||
{hasStages ? (
|
||||
<ResponsiveContainer width="100%" height={170}>
|
||||
<BarChart
|
||||
data={stages}
|
||||
layout="vertical"
|
||||
margin={{ top: 4, right: 16, left: 8, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="var(--mantine-color-gray-2)" />
|
||||
<XAxis type="number" tick={{ fontSize: 12 }} unit="d" />
|
||||
<YAxis type="category" dataKey="label" width={130} tick={{ fontSize: 11 }} />
|
||||
<Tooltip
|
||||
cursor={{ fill: 'var(--mantine-color-gray-1)' }}
|
||||
formatter={(value) => [`${value} days`, 'Avg'] as [string, string]}
|
||||
/>
|
||||
<Bar dataKey="avgDays" name="Avg days" fill="#12b886" radius={[0, 6, 6, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Group justify="center" align="center" h={170}>
|
||||
<Text c="dimmed" size="sm">
|
||||
Not enough dispatched items yet to chart stage times.
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Card, Group, Loader, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import { Hourglass } from 'lucide-react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { useWarehouseDwellStats } from '@/hooks/useWarehouses';
|
||||
import { formatDays } from './options';
|
||||
|
||||
/** Green → amber → red as items age. Aligned with the zone-occupancy heat scale. */
|
||||
const BUCKET_COLORS = ['#12b886', '#40c057', '#f08c00', '#fa5252'];
|
||||
|
||||
/**
|
||||
* Dwell time of items still in the warehouse: the average, plus how the current
|
||||
* stock is spread across aging buckets (0–3 / 4–7 / 8–14 / 15+ days).
|
||||
*/
|
||||
export function DwellAgingCard() {
|
||||
const { data, isLoading } = useWarehouseDwellStats();
|
||||
const buckets = data?.buckets ?? [];
|
||||
const hasItems = (data?.inWarehouseCount ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<Card withBorder radius="lg" padding="lg" h="100%">
|
||||
<Group gap="sm" mb="md">
|
||||
<ThemeIcon size="lg" radius="md" color="grape" variant="light">
|
||||
<Hourglass size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Dwell time & aging</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
How long current stock has been held
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" h={220}>
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<Group align="stretch" gap="lg" wrap="nowrap">
|
||||
<Stack gap={2} justify="center" style={{ minWidth: 110 }}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
Avg dwell
|
||||
</Text>
|
||||
<Text fw={800} fz={30} lh={1.1}>
|
||||
{hasItems ? formatDays(data?.avgDwellDays) : '—'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{data?.inWarehouseCount ?? 0} item{(data?.inWarehouseCount ?? 0) === 1 ? '' : 's'} in warehouse
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{hasItems ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart
|
||||
data={buckets}
|
||||
layout="vertical"
|
||||
margin={{ top: 4, right: 16, left: 8, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="var(--mantine-color-gray-2)" />
|
||||
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 12 }} />
|
||||
<YAxis type="category" dataKey="label" width={72} tick={{ fontSize: 12 }} />
|
||||
<Tooltip cursor={{ fill: 'var(--mantine-color-gray-1)' }} />
|
||||
<Bar dataKey="count" name="Items" radius={[0, 6, 6, 0]}>
|
||||
{buckets.map((b, i) => (
|
||||
<Cell key={b.key} fill={BUCKET_COLORS[i] ?? '#868e96'} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Group justify="center" align="center" h={200}>
|
||||
<Text c="dimmed" size="sm">
|
||||
No items currently in the warehouse.
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,10 @@ export function WarehouseOpsKpiStrip() {
|
||||
value: data?.receivedToday ?? 0,
|
||||
icon: PackageCheck,
|
||||
color: "edr-green",
|
||||
// Live signal: change vs yesterday's received count.
|
||||
delta:
|
||||
data != null ? data.receivedToday - data.receivedYesterday : undefined,
|
||||
hint: "vs yesterday",
|
||||
},
|
||||
{
|
||||
label: "Pending inspection",
|
||||
|
||||
@@ -32,3 +32,5 @@ export { FeePreviewModal } from './FeePreviewModal';
|
||||
export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap';
|
||||
export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip';
|
||||
export { AccrualDashboard } from './AccrualDashboard';
|
||||
export { DwellAgingCard } from './DwellAgingCard';
|
||||
export { CycleTimeCard } from './CycleTimeCard';
|
||||
|
||||
@@ -35,6 +35,14 @@ export const formatCapacity = (current: number, capacity: number | null | undefi
|
||||
return `${cur} / ${formatNumber(capacity)}`;
|
||||
};
|
||||
|
||||
/** A day count as a short, human duration: "0.2d" / "3.5 days" / "—". */
|
||||
export const formatDays = (value: number | null | undefined) => {
|
||||
if (value === null || value === undefined || Number.isNaN(Number(value))) return '—';
|
||||
const num = Number(value);
|
||||
const rounded = Math.round(num * 10) / 10;
|
||||
return `${rounded} ${rounded === 1 ? 'day' : 'days'}`;
|
||||
};
|
||||
|
||||
export const formatDate = (value: string | null | undefined) => {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
|
||||
Reference in New Issue
Block a user