mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +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:
@@ -73,6 +73,14 @@ export class WarehouseInventoryController {
|
|||||||
return this.inventoryService.zoneOccupancy(yardId);
|
return this.inventoryService.zoneOccupancy(yardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('throughput')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||||
|
@ApiOperation({ summary: 'Received-vs-dispatched throughput time series (week/month/year)' })
|
||||||
|
throughput(@Query('granularity') granularity?: string) {
|
||||||
|
const g = granularity === 'week' || granularity === 'year' ? granularity : 'month';
|
||||||
|
return this.inventoryService.throughput(g);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('auto-unload-arrived')
|
@Post('auto-unload-arrived')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
||||||
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
|
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
|
||||||
|
|||||||
@@ -436,6 +436,53 @@ export class WarehouseInventoryService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* generate_series so empty periods still return a zero row — replaces the
|
||||||
|
* client-side approach that downloaded the whole inventory to bucket it.
|
||||||
|
*/
|
||||||
|
async throughput(
|
||||||
|
granularity: 'week' | 'month' | 'year' = 'month',
|
||||||
|
): Promise<Array<{ periodStart: string; received: number; dispatched: number }>> {
|
||||||
|
// Whitelist the unit — it is interpolated into date_trunc / interval literals.
|
||||||
|
const unit: 'week' | 'month' | 'year' = ['week', 'month', 'year'].includes(granularity)
|
||||||
|
? granularity
|
||||||
|
: 'month';
|
||||||
|
const back = unit === 'week' ? 7 : unit === 'month' ? 11 : 4;
|
||||||
|
|
||||||
|
const rows: Array<{ periodStart: string; received: number; dispatched: number }> =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`WITH periods AS (
|
||||||
|
SELECT gs AS period_start
|
||||||
|
FROM generate_series(
|
||||||
|
date_trunc('${unit}', now()) - ($1 || ' ${unit}')::interval,
|
||||||
|
date_trunc('${unit}', now()),
|
||||||
|
'1 ${unit}'::interval
|
||||||
|
) gs
|
||||||
|
)
|
||||||
|
SELECT p.period_start AS "periodStart",
|
||||||
|
COALESCE(r.cnt, 0)::int AS received,
|
||||||
|
COALESCE(d.cnt, 0)::int AS dispatched
|
||||||
|
FROM periods p
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT date_trunc('${unit}', arrived_at) AS ps, count(*) AS cnt
|
||||||
|
FROM freight.warehouse_inventory
|
||||||
|
WHERE deleted_at IS NULL AND arrived_at IS NOT NULL
|
||||||
|
GROUP BY 1
|
||||||
|
) r ON r.ps = p.period_start
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT date_trunc('${unit}', dispatched_at) AS ps, count(*) AS cnt
|
||||||
|
FROM freight.warehouse_inventory
|
||||||
|
WHERE deleted_at IS NULL AND dispatched_at IS NOT NULL
|
||||||
|
GROUP BY 1
|
||||||
|
) d ON d.ps = p.period_start
|
||||||
|
ORDER BY p.period_start`,
|
||||||
|
[back],
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live occupancy per zone: rated capacity vs the weight/items currently held
|
* Live occupancy per zone: rated capacity vs the weight/items currently held
|
||||||
* (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard
|
* (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard
|
||||||
|
|||||||
@@ -1,24 +1,20 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Card, Group, SegmentedControl, SimpleGrid, Text, ThemeIcon } from '@mantine/core';
|
import { Card, Group, SegmentedControl, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||||
import { BarChart3, CalendarRange, PieChart as PieChartIcon } from 'lucide-react';
|
import { BarChart3, CalendarRange } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Cell,
|
Cell,
|
||||||
Legend,
|
Legend,
|
||||||
Pie,
|
|
||||||
PieChart,
|
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useWarehouseThroughput } from '@/hooks/useWarehouses';
|
||||||
|
import type { WarehouseDashboard } from '@/types/warehouse';
|
||||||
import { api } from '@/services/api';
|
|
||||||
import type { WarehouseDashboard, WarehouseInventoryItem } from '@/types/warehouse';
|
|
||||||
|
|
||||||
interface WarehouseDashboardChartsProps {
|
interface WarehouseDashboardChartsProps {
|
||||||
data?: WarehouseDashboard;
|
data?: WarehouseDashboard;
|
||||||
@@ -38,11 +34,25 @@ const STATUS_SERIES = [
|
|||||||
|
|
||||||
type Granularity = 'week' | 'month' | 'year';
|
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) {
|
export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps) {
|
||||||
const [granularity, setGranularity] = useState<Granularity>('month');
|
const [granularity, setGranularity] = useState<Granularity>('month');
|
||||||
const { data: inventory } = useQuery(
|
// Server-side time series (replaces downloading the whole inventory to bucket).
|
||||||
api.warehouses.listInventory.queryOptions({ input: {} }),
|
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) => ({
|
const statusData = STATUS_SERIES.map((s) => ({
|
||||||
name: s.label,
|
name: s.label,
|
||||||
@@ -51,16 +61,10 @@ export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps
|
|||||||
}));
|
}));
|
||||||
const hasStatus = statusData.some((d) => d.value > 0);
|
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 (
|
return (
|
||||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
<Stack gap="md">
|
||||||
{/* Time-filtered throughput */}
|
{/* 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 justify="space-between" mb="md" wrap="wrap">
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: ORANGE, color: '#fff' }}>
|
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: ORANGE, color: '#fff' }}>
|
||||||
@@ -133,103 +137,10 @@ export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps
|
|||||||
<EmptyChart />
|
<EmptyChart />
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
</Stack>
|
||||||
{/* 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
function EmptyChart() {
|
||||||
return (
|
return (
|
||||||
<Group justify="center" align="center" h={280}>
|
<Group justify="center" align="center" h={280}>
|
||||||
|
|||||||
@@ -487,6 +487,8 @@ export const URL_CONSTANTS = {
|
|||||||
RESERVE: "/warehouse-inventory/reserve",
|
RESERVE: "/warehouse-inventory/reserve",
|
||||||
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
|
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
|
||||||
OPS_STATS: "/warehouse-inventory/ops-stats",
|
OPS_STATS: "/warehouse-inventory/ops-stats",
|
||||||
|
THROUGHPUT: (granularity: 'week' | 'month' | 'year') =>
|
||||||
|
`/warehouse-inventory/throughput?granularity=${granularity}`,
|
||||||
ZONE_OCCUPANCY: (yardId?: string) =>
|
ZONE_OCCUPANCY: (yardId?: string) =>
|
||||||
yardId
|
yardId
|
||||||
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`
|
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`
|
||||||
|
|||||||
@@ -152,6 +152,14 @@ export function useWarehouseOpsStats() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Server-side received-vs-dispatched throughput time series. */
|
||||||
|
export function useWarehouseThroughput(granularity: 'week' | 'month' | 'year') {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-inventory', 'throughput', granularity],
|
||||||
|
queryFn: () => warehouseService.throughput(granularity).then((r) => r.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Live per-item fee accrual (storage/demurrage) with alerts. */
|
/** Live per-item fee accrual (storage/demurrage) with alerts. */
|
||||||
export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') {
|
export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Card, Center, Group, Loader, SimpleGrid, Text, ThemeIcon } from '@mantine/core';
|
import { Card, Center, Divider, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
@@ -16,10 +16,23 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from '@/components/page';
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
import { WarehouseDashboardCharts } from '@/components/warehouses';
|
import {
|
||||||
|
AccrualDashboard,
|
||||||
|
WarehouseDashboardCharts,
|
||||||
|
WarehouseOpsKpiStrip,
|
||||||
|
ZoneOccupancyHeatmap,
|
||||||
|
} from '@/components/warehouses';
|
||||||
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
|
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
|
||||||
import type { WarehouseDashboard } from '@/types/warehouse';
|
import type { WarehouseDashboard } from '@/types/warehouse';
|
||||||
|
|
||||||
|
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Text fw={700} fz="sm" tt="uppercase" c="edr-muted" style={{ letterSpacing: 0.4 }}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface Metric {
|
interface Metric {
|
||||||
key: keyof WarehouseDashboard;
|
key: keyof WarehouseDashboard;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -67,7 +80,16 @@ export default function WarehouseDashboardPage() {
|
|||||||
<Text c="red">Failed to load warehouse dashboard.</Text>
|
<Text c="red">Failed to load warehouse dashboard.</Text>
|
||||||
</Center>
|
</Center>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Stack gap="xl">
|
||||||
|
{/* Needs attention — live ops counters (received today, pending
|
||||||
|
inspection, trucks on-site, items aging > 7 days). */}
|
||||||
|
<Stack gap="sm">
|
||||||
|
<SectionTitle>Needs attention</SectionTitle>
|
||||||
|
<WarehouseOpsKpiStrip />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
|
||||||
{METRICS.map((metric) => (
|
{METRICS.map((metric) => (
|
||||||
<Card
|
<Card
|
||||||
@@ -98,8 +120,21 @@ export default function WarehouseDashboardPage() {
|
|||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<WarehouseDashboardCharts data={data} />
|
<Stack gap="sm">
|
||||||
</>
|
<SectionTitle>Flow</SectionTitle>
|
||||||
|
<WarehouseDashboardCharts data={data} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<SectionTitle>Zone capacity</SectionTitle>
|
||||||
|
<ZoneOccupancyHeatmap />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<SectionTitle>Demurrage & storage exceptions</SectionTitle>
|
||||||
|
<AccrualDashboard />
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { URL_CONSTANTS } from '@/constants/URLS';
|
|||||||
import type {
|
import type {
|
||||||
ZoneOccupancy,
|
ZoneOccupancy,
|
||||||
WarehouseOpsStats,
|
WarehouseOpsStats,
|
||||||
|
WarehouseThroughputPoint,
|
||||||
AccrualDashboardRow,
|
AccrualDashboardRow,
|
||||||
AllocationCriteria,
|
AllocationCriteria,
|
||||||
AllocationPreviewResult,
|
AllocationPreviewResult,
|
||||||
@@ -394,6 +395,10 @@ export const warehouseService = {
|
|||||||
),
|
),
|
||||||
opsStats: () =>
|
opsStats: () =>
|
||||||
apiClient.get<WarehouseOpsStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.OPS_STATS),
|
apiClient.get<WarehouseOpsStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.OPS_STATS),
|
||||||
|
throughput: (granularity: 'week' | 'month' | 'year') =>
|
||||||
|
apiClient.get<WarehouseThroughputPoint[]>(
|
||||||
|
URL_CONSTANTS.WAREHOUSE_INVENTORY.THROUGHPUT(granularity),
|
||||||
|
),
|
||||||
autoUnloadArrived: () =>
|
autoUnloadArrived: () =>
|
||||||
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
|
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
|
||||||
autoLoadReady: () =>
|
autoLoadReady: () =>
|
||||||
|
|||||||
@@ -1120,6 +1120,13 @@ export interface WarehouseOpsStats {
|
|||||||
itemsAging: number;
|
itemsAging: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One bucket of the received-vs-dispatched throughput time series. */
|
||||||
|
export interface WarehouseThroughputPoint {
|
||||||
|
periodStart: string;
|
||||||
|
received: number;
|
||||||
|
dispatched: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
|
export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
|
||||||
|
|
||||||
/** One item's live fee accrual for the accrual dashboard. */
|
/** One item's live fee accrual for the accrual dashboard. */
|
||||||
|
|||||||
Reference in New Issue
Block a user