Merge pull request #704 from Tria-plc/accrualdashboard

feat(warehouse): assemble dashboard cockpit + server-side throughput …
This commit is contained in:
Hagernesh Tadesse
2026-07-15 15:32:41 +03:00
committed by GitHub
8 changed files with 142 additions and 119 deletions

View File

@@ -73,6 +73,14 @@ export class WarehouseInventoryController {
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')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })

View File

@@ -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
* (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard

View File

@@ -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}>

View File

@@ -487,6 +487,8 @@ export const URL_CONSTANTS = {
RESERVE: "/warehouse-inventory/reserve",
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
OPS_STATS: "/warehouse-inventory/ops-stats",
THROUGHPUT: (granularity: 'week' | 'month' | 'year') =>
`/warehouse-inventory/throughput?granularity=${granularity}`,
ZONE_OCCUPANCY: (yardId?: string) =>
yardId
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`

View File

@@ -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. */
export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') {
return useQuery({

View File

@@ -1,5 +1,5 @@
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 {
ClipboardCheck,
ClipboardList,
@@ -16,10 +16,23 @@ import {
} from 'lucide-react';
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 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 {
key: keyof WarehouseDashboard;
label: string;
@@ -67,7 +80,16 @@ export default function WarehouseDashboardPage() {
<Text c="red">Failed to load warehouse dashboard.</Text>
</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">
{METRICS.map((metric) => (
<Card
@@ -98,8 +120,21 @@ export default function WarehouseDashboardPage() {
))}
</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 &amp; storage exceptions</SectionTitle>
<AccrualDashboard />
</Stack>
</Stack>
)}
</PageContainer>
);

View File

@@ -6,6 +6,7 @@ import { URL_CONSTANTS } from '@/constants/URLS';
import type {
ZoneOccupancy,
WarehouseOpsStats,
WarehouseThroughputPoint,
AccrualDashboardRow,
AllocationCriteria,
AllocationPreviewResult,
@@ -394,6 +395,10 @@ export const warehouseService = {
),
opsStats: () =>
apiClient.get<WarehouseOpsStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.OPS_STATS),
throughput: (granularity: 'week' | 'month' | 'year') =>
apiClient.get<WarehouseThroughputPoint[]>(
URL_CONSTANTS.WAREHOUSE_INVENTORY.THROUGHPUT(granularity),
),
autoUnloadArrived: () =>
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
autoLoadReady: () =>

View File

@@ -1120,6 +1120,13 @@ export interface WarehouseOpsStats {
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';
/** One item's live fee accrual for the accrual dashboard. */