mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #708 from Tria-plc/wh-dashboard
feat(warehouse): P1 dashboard analytics — dwell/aging, cycle-time + o… Dwell/aging + cycle-time/on-time + live-delta tiles on the warehouse cockpit. Roadmap remaining (P2): gate turn-time (aggregate gate_cleared_at), capacity forecast, labour/equipment productivity, real-time push.
This commit is contained in:
@@ -330,6 +330,80 @@ export class WarehouseFeeService {
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* On-time dispatch rate: the share of items dispatched in the last N days that
|
||||
* LEFT before their storage free-days expired — CEIL((dispatched−arrived)/day)
|
||||
* <= freeDays, with freeDays resolved by the same rule matching the fee engine
|
||||
* uses (bestRule over active STORAGE_FEE rules). onTimePct is null when there
|
||||
* is nothing to measure (e.g. no dispatched items / no storage rules).
|
||||
*/
|
||||
async onTimeDispatchStats(
|
||||
windowDays = 90,
|
||||
): Promise<{ sampleSize: number; onTimeCount: number; onTimePct: number | null }> {
|
||||
const storageRules = (
|
||||
await this.feeRuleRepository.findAll({ where: { isActive: true } })
|
||||
).filter((r) => r.ruleType === 'STORAGE_FEE');
|
||||
|
||||
// Batched attribute pull mirroring loadItem's scope joins (multi-row) — only
|
||||
// the fields bestRule/matchScore reads, plus the two clock timestamps.
|
||||
const rows: Array<
|
||||
ItemAttributes & { arrivedAt: string; dispatchedAt: string }
|
||||
> = await this.dataSource.query(
|
||||
`SELECT inv.arrived_at AS "arrivedAt",
|
||||
inv.dispatched_at AS "dispatchedAt",
|
||||
inv.warehouse_id AS "warehouseId",
|
||||
inv.yard_id AS "yardId",
|
||||
inv.zone_id AS "zoneId",
|
||||
w.facility_id AS "facilityId",
|
||||
b.freight_type AS "freightType",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
|
||||
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
|
||||
NULL AS "vehicleType"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
|
||||
LEFT JOIN freight.cargo_types booking_cgt ON booking_cgt.id = b.cargo_type_id
|
||||
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
||||
LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT bc.container_type_id
|
||||
FROM freight.booking_container bc
|
||||
WHERE bc.booking_id = inv.booking_id
|
||||
AND bc.deleted_at IS NULL
|
||||
AND bc.container_type_id IS NOT NULL
|
||||
ORDER BY bc.created_at ASC
|
||||
LIMIT 1
|
||||
) booking_container_type ON true
|
||||
LEFT JOIN freight.container_types booking_ctt ON booking_ctt.id = booking_container_type.container_type_id
|
||||
WHERE inv.deleted_at IS NULL
|
||||
AND inv.arrived_at IS NOT NULL
|
||||
AND inv.dispatched_at IS NOT NULL
|
||||
AND inv.dispatched_at > now() - ($1 || ' days')::interval`,
|
||||
[windowDays],
|
||||
);
|
||||
|
||||
let onTimeCount = 0;
|
||||
for (const row of rows) {
|
||||
const freeDays = this.bestRule(storageRules, row)?.freeDays ?? 0;
|
||||
const elapsed = Math.max(
|
||||
0,
|
||||
Math.ceil(
|
||||
(new Date(row.dispatchedAt).getTime() - new Date(row.arrivedAt).getTime()) / MS_PER_DAY,
|
||||
),
|
||||
);
|
||||
if (elapsed <= freeDays) onTimeCount += 1;
|
||||
}
|
||||
const sampleSize = rows.length;
|
||||
return {
|
||||
sampleSize,
|
||||
onTimeCount,
|
||||
onTimePct: sampleSize ? Math.round((onTimeCount / sampleSize) * 100) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' {
|
||||
return currency === 'ETB' ? 'ETB' : 'USD';
|
||||
}
|
||||
|
||||
@@ -81,6 +81,20 @@ export class WarehouseInventoryController {
|
||||
return this.inventoryService.throughput(g);
|
||||
}
|
||||
|
||||
@Get('dwell-stats')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Dwell time of in-warehouse items: average + aging buckets' })
|
||||
dwellStats() {
|
||||
return this.inventoryService.dwellStats();
|
||||
}
|
||||
|
||||
@Get('cycle-stats')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Average stage cycle times over recently dispatched items' })
|
||||
cycleStats() {
|
||||
return this.inventoryService.cycleStats();
|
||||
}
|
||||
|
||||
@Post('auto-unload-arrived')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
||||
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
|
||||
|
||||
@@ -406,12 +406,14 @@ export class WarehouseInventoryService {
|
||||
*/
|
||||
async opsStats(): Promise<{
|
||||
receivedToday: number;
|
||||
receivedYesterday: number;
|
||||
pendingInspection: number;
|
||||
trucksOnSite: number;
|
||||
itemsAging: number;
|
||||
}> {
|
||||
const [row]: Array<{
|
||||
receivedToday: number;
|
||||
receivedYesterday: number;
|
||||
pendingInspection: number;
|
||||
trucksOnSite: number;
|
||||
itemsAging: number;
|
||||
@@ -419,6 +421,8 @@ export class WarehouseInventoryService {
|
||||
`SELECT
|
||||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||||
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE) AS "receivedToday",
|
||||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||||
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE - 1) AS "receivedYesterday",
|
||||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||||
WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection",
|
||||
(SELECT count(*)::int FROM freight.customer_truck_assignments
|
||||
@@ -430,12 +434,109 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
return {
|
||||
receivedToday: row?.receivedToday ?? 0,
|
||||
receivedYesterday: row?.receivedYesterday ?? 0,
|
||||
pendingInspection: row?.pendingInspection ?? 0,
|
||||
trucksOnSite: row?.trucksOnSite ?? 0,
|
||||
itemsAging: row?.itemsAging ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** In-warehouse statuses used by the dwell / aging metrics. */
|
||||
private readonly IN_WAREHOUSE_STATUSES = [
|
||||
'RECEIVED',
|
||||
'UNLOADED',
|
||||
'STORED',
|
||||
'RESERVED',
|
||||
'READY_FOR_LOADING',
|
||||
'READY_FOR_PICKUP',
|
||||
];
|
||||
|
||||
/**
|
||||
* Dwell time of items still in the warehouse: average days held plus a count
|
||||
* per aging bucket (0–3 / 4–7 / 8–14 / 15+). Clock starts at arrival (falling
|
||||
* back to created_at). Powers the dwell / aging histogram.
|
||||
*/
|
||||
async dwellStats(): Promise<{
|
||||
avgDwellDays: number;
|
||||
inWarehouseCount: number;
|
||||
buckets: Array<{ key: string; label: string; count: number }>;
|
||||
}> {
|
||||
const [row]: Array<{
|
||||
avgDwellDays: number | null;
|
||||
inWarehouseCount: number;
|
||||
b0: number;
|
||||
b1: number;
|
||||
b2: number;
|
||||
b3: number;
|
||||
}> = await this.dataSource.query(
|
||||
`WITH held AS (
|
||||
SELECT EXTRACT(EPOCH FROM (now() - COALESCE(arrived_at, created_at))) / 86400.0 AS age_days
|
||||
FROM freight.warehouse_inventory
|
||||
WHERE deleted_at IS NULL
|
||||
AND status = ANY($1)
|
||||
)
|
||||
SELECT COALESCE(round(avg(age_days)::numeric, 1), 0)::float8 AS "avgDwellDays",
|
||||
count(*)::int AS "inWarehouseCount",
|
||||
count(*) FILTER (WHERE age_days < 4)::int AS b0,
|
||||
count(*) FILTER (WHERE age_days >= 4 AND age_days < 8)::int AS b1,
|
||||
count(*) FILTER (WHERE age_days >= 8 AND age_days < 15)::int AS b2,
|
||||
count(*) FILTER (WHERE age_days >= 15)::int AS b3
|
||||
FROM held`,
|
||||
[this.IN_WAREHOUSE_STATUSES],
|
||||
);
|
||||
return {
|
||||
avgDwellDays: row?.avgDwellDays ?? 0,
|
||||
inWarehouseCount: row?.inWarehouseCount ?? 0,
|
||||
buckets: [
|
||||
{ key: '0-3', label: '0–3 days', count: row?.b0 ?? 0 },
|
||||
{ key: '4-7', label: '4–7 days', count: row?.b1 ?? 0 },
|
||||
{ key: '8-14', label: '8–14 days', count: row?.b2 ?? 0 },
|
||||
{ key: '15+', label: '15+ days', count: row?.b3 ?? 0 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Average stage cycle times over items dispatched in the last 90 days:
|
||||
* arrived→ready, ready→loaded, loaded→dispatched, and the total
|
||||
* arrived→dispatched (dock-to-dispatch). Days, to one decimal.
|
||||
*/
|
||||
async cycleStats(): Promise<{
|
||||
sampleSize: number;
|
||||
avgDockToDispatchDays: number;
|
||||
stages: Array<{ key: string; label: string; avgDays: number }>;
|
||||
}> {
|
||||
const gapDays = (from: string, to: string) =>
|
||||
`round((avg(EXTRACT(EPOCH FROM (${to} - ${from})) / 86400.0) FILTER (WHERE ${from} IS NOT NULL AND ${to} IS NOT NULL))::numeric, 1)::float8`;
|
||||
const [row]: Array<{
|
||||
sampleSize: number;
|
||||
total: number | null;
|
||||
arrivedReady: number | null;
|
||||
readyLoaded: number | null;
|
||||
loadedDispatched: number | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT count(*)::int AS "sampleSize",
|
||||
${gapDays('arrived_at', 'dispatched_at')} AS "total",
|
||||
${gapDays('arrived_at', 'ready_for_loading_at')} AS "arrivedReady",
|
||||
${gapDays('ready_for_loading_at', 'loaded_at')} AS "readyLoaded",
|
||||
${gapDays('loaded_at', 'dispatched_at')} AS "loadedDispatched"
|
||||
FROM freight.warehouse_inventory
|
||||
WHERE deleted_at IS NULL
|
||||
AND arrived_at IS NOT NULL
|
||||
AND dispatched_at IS NOT NULL
|
||||
AND dispatched_at > now() - interval '90 days'`,
|
||||
);
|
||||
return {
|
||||
sampleSize: row?.sampleSize ?? 0,
|
||||
avgDockToDispatchDays: row?.total ?? 0,
|
||||
stages: [
|
||||
{ key: 'arrived-ready', label: 'Arrived → Ready', avgDays: row?.arrivedReady ?? 0 },
|
||||
{ key: 'ready-loaded', label: 'Ready → Loaded', avgDays: row?.readyLoaded ?? 0 },
|
||||
{ key: 'loaded-dispatched', label: 'Loaded → Dispatched', avgDays: row?.loadedDispatched ?? 0 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -96,6 +96,13 @@ export class WarehouseRulesController {
|
||||
return this.feeService.accrualDashboard(billingCurrency);
|
||||
}
|
||||
|
||||
@Get('warehouse-fees/on-time-dispatch')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
|
||||
@ApiOperation({ summary: 'On-time dispatch rate — items that left before storage free-days expired' })
|
||||
onTimeDispatch() {
|
||||
return this.feeService.onTimeDispatchStats();
|
||||
}
|
||||
|
||||
@Post('warehouse-fees/accrual/:inventoryId/acknowledge')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
|
||||
@ApiOperation({ summary: 'Acknowledge / snooze an item fee-accrual alert' })
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -492,6 +492,8 @@ export const URL_CONSTANTS = {
|
||||
OPS_STATS: "/warehouse-inventory/ops-stats",
|
||||
THROUGHPUT: (granularity: 'week' | 'month' | 'year') =>
|
||||
`/warehouse-inventory/throughput?granularity=${granularity}`,
|
||||
DWELL_STATS: "/warehouse-inventory/dwell-stats",
|
||||
CYCLE_STATS: "/warehouse-inventory/cycle-stats",
|
||||
ZONE_OCCUPANCY: (yardId?: string) =>
|
||||
yardId
|
||||
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`
|
||||
@@ -564,6 +566,7 @@ export const URL_CONSTANTS = {
|
||||
FEE_PREVIEW: (inventoryId: string) =>
|
||||
`/warehouse-inventory/${inventoryId}/fee-preview`,
|
||||
ACCRUAL_DASHBOARD: "/warehouse-fees/accrual-dashboard",
|
||||
ON_TIME_DISPATCH: "/warehouse-fees/on-time-dispatch",
|
||||
ACCRUAL_ACK: (inventoryId: string) =>
|
||||
`/warehouse-fees/accrual/${inventoryId}/acknowledge`,
|
||||
},
|
||||
|
||||
@@ -160,6 +160,30 @@ export function useWarehouseThroughput(granularity: 'week' | 'month' | 'year') {
|
||||
});
|
||||
}
|
||||
|
||||
/** Dwell time of in-warehouse items (average + aging buckets). */
|
||||
export function useWarehouseDwellStats() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'dwell-stats'],
|
||||
queryFn: () => warehouseService.dwellStats().then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
/** Average stage cycle times over recently dispatched items. */
|
||||
export function useWarehouseCycleStats() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'cycle-stats'],
|
||||
queryFn: () => warehouseService.cycleStats().then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
/** On-time dispatch rate (left before storage free-days expired). */
|
||||
export function useOnTimeDispatch() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-fees', 'on-time-dispatch'],
|
||||
queryFn: () => warehouseService.onTimeDispatch().then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
/** Live per-item fee accrual (storage/demurrage) with alerts. */
|
||||
export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') {
|
||||
return useQuery({
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import {
|
||||
AccrualDashboard,
|
||||
CycleTimeCard,
|
||||
DwellAgingCard,
|
||||
WarehouseDashboardCharts,
|
||||
WarehouseOpsKpiStrip,
|
||||
ZoneOccupancyHeatmap,
|
||||
@@ -125,6 +127,14 @@ export default function WarehouseDashboardPage() {
|
||||
<WarehouseDashboardCharts data={data} />
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<SectionTitle>Performance</SectionTitle>
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
<DwellAgingCard />
|
||||
<CycleTimeCard />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<SectionTitle>Zone capacity</SectionTitle>
|
||||
<ZoneOccupancyHeatmap />
|
||||
|
||||
@@ -7,6 +7,9 @@ import type {
|
||||
ZoneOccupancy,
|
||||
WarehouseOpsStats,
|
||||
WarehouseThroughputPoint,
|
||||
WarehouseDwellStats,
|
||||
WarehouseCycleStats,
|
||||
WarehouseOnTimeStats,
|
||||
AccrualDashboardRow,
|
||||
AllocationCriteria,
|
||||
AllocationPreviewResult,
|
||||
@@ -399,6 +402,10 @@ export const warehouseService = {
|
||||
apiClient.get<WarehouseThroughputPoint[]>(
|
||||
URL_CONSTANTS.WAREHOUSE_INVENTORY.THROUGHPUT(granularity),
|
||||
),
|
||||
dwellStats: () =>
|
||||
apiClient.get<WarehouseDwellStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DWELL_STATS),
|
||||
cycleStats: () =>
|
||||
apiClient.get<WarehouseCycleStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.CYCLE_STATS),
|
||||
autoUnloadArrived: () =>
|
||||
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
|
||||
autoLoadReady: () =>
|
||||
@@ -455,6 +462,8 @@ export const warehouseService = {
|
||||
apiClient.get<AccrualDashboardRow[]>(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, {
|
||||
params: cleanParams({ billingCurrency }),
|
||||
}),
|
||||
onTimeDispatch: () =>
|
||||
apiClient.get<WarehouseOnTimeStats>(URL_CONSTANTS.WAREHOUSE_RULES.ON_TIME_DISPATCH),
|
||||
acknowledgeAccrual: (inventoryId: string, body: { snoozeDays?: number; note?: string } = {}) =>
|
||||
apiClient.post(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_ACK(inventoryId), body),
|
||||
unacknowledgeAccrual: (inventoryId: string) =>
|
||||
|
||||
@@ -1115,6 +1115,7 @@ export interface ZoneOccupancy {
|
||||
/** At-a-glance warehouse ops counters for the KPI strip. */
|
||||
export interface WarehouseOpsStats {
|
||||
receivedToday: number;
|
||||
receivedYesterday: number;
|
||||
pendingInspection: number;
|
||||
trucksOnSite: number;
|
||||
itemsAging: number;
|
||||
@@ -1127,6 +1128,27 @@ export interface WarehouseThroughputPoint {
|
||||
dispatched: number;
|
||||
}
|
||||
|
||||
/** Dwell time of in-warehouse items: average days + aging-bucket counts. */
|
||||
export interface WarehouseDwellStats {
|
||||
avgDwellDays: number;
|
||||
inWarehouseCount: number;
|
||||
buckets: Array<{ key: string; label: string; count: number }>;
|
||||
}
|
||||
|
||||
/** Average stage cycle times over recently dispatched items. */
|
||||
export interface WarehouseCycleStats {
|
||||
sampleSize: number;
|
||||
avgDockToDispatchDays: number;
|
||||
stages: Array<{ key: string; label: string; avgDays: number }>;
|
||||
}
|
||||
|
||||
/** On-time dispatch rate (items that left before storage free-days expired). */
|
||||
export interface WarehouseOnTimeStats {
|
||||
sampleSize: number;
|
||||
onTimeCount: number;
|
||||
onTimePct: number | null;
|
||||
}
|
||||
|
||||
export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
|
||||
|
||||
/** One item's live fee accrual for the accrual dashboard. */
|
||||
|
||||
Reference in New Issue
Block a user