feat(warehouses): make dashboard date range + warehouse filter real

Backs the previously-dead header date-picker and Filters button with
an actual scoped query. getDashboard() takes optional dateFrom/dateTo/
warehouseId: received (renamed from receivedToday) is the only
activity counter and respects the date range, defaulting to today when
omitted; the 10 status-backlog counters respect warehouseId only;
totalWarehouses/emptyContainers/importTrains/exportTrains stay global
since nothing ties them to a specific warehouse in the schema. Adds a
warehouse Select + date-range picker to the dashboard header, with a
caption spelling out what is and isn't scoped.
This commit is contained in:
Hagernesh
2026-08-20 13:29:37 +00:00
parent 572b42343f
commit 7f772efcb3
6 changed files with 155 additions and 57 deletions

View File

@@ -5,26 +5,34 @@ import { Warehouse } from './entities/warehouse.entity';
import { WarehouseInventory } from './entities/warehouse-inventory.entity'; import { WarehouseInventory } from './entities/warehouse-inventory.entity';
import { SchedulingReadFacade } from './scheduling-read.facade'; import { SchedulingReadFacade } from './scheduling-read.facade';
export interface WarehouseDashboardFilter {
/** Inclusive day, `YYYY-MM-DD`. Both omitted → defaults to "today" (the original behaviour). */
dateFrom?: string;
dateTo?: string;
/** Scopes every warehouse_inventory-derived counter. Ignored by the always-global ones (see below). */
warehouseId?: string;
}
export interface WarehouseDashboard { export interface WarehouseDashboard {
// ── Always current — dateFrom/dateTo have no effect on these ──────────────
totalWarehouses: number; totalWarehouses: number;
totalInventory: number; totalInventory: number;
receivedToday: number;
// Inspection gate
awaitingInspection: number; awaitingInspection: number;
inspected: number; inspected: number;
// Export branch
stored: number; stored: number;
reserved: number; reserved: number;
readyForLoading: number; readyForLoading: number;
loaded: number; loaded: number;
dispatched: number; dispatched: number;
// Import branch
readyForPickup: number; readyForPickup: number;
delivered: number; delivered: number;
// Fleet / train snapshot /** Never warehouse-scoped — the container fleet isn't tied to a specific warehouse. */
emptyContainers: number; emptyContainers: number;
/** Never warehouse-scoped — trains aren't tied to a specific warehouse. */
importTrains: number; importTrains: number;
exportTrains: number; exportTrains: number;
// ── The one activity counter — respects dateFrom/dateTo (default: today) ──
received: number;
} }
@Injectable() @Injectable()
@@ -72,24 +80,40 @@ export class WarehouseDashboardService {
} }
} }
private async safeReceivedToday(startOfToday: Date): Promise<number> { /** [inclusive start, exclusive end) for the "received" counter. Defaults to today. */
private resolveRange(filter: WarehouseDashboardFilter): { start: Date; end: Date } {
if (!filter.dateFrom && !filter.dateTo) {
const start = new Date();
start.setHours(0, 0, 0, 0);
return { start, end: new Date() };
}
const start = filter.dateFrom ? new Date(`${filter.dateFrom}T00:00:00`) : new Date(0);
// Exclusive end = start of the day AFTER dateTo, so the whole end day is included.
const end = filter.dateTo
? new Date(new Date(`${filter.dateTo}T00:00:00`).getTime() + 24 * 60 * 60 * 1000)
: new Date();
return { start, end };
}
private async safeReceived(range: { start: Date; end: Date }, warehouseId?: string): Promise<number> {
try { try {
return await this.dataSource const qb = this.dataSource
.getRepository(WarehouseInventory) .getRepository(WarehouseInventory)
.createQueryBuilder('inv') .createQueryBuilder('inv')
.where('inv.arrived_at >= :start', { start: startOfToday }) .where('inv.arrived_at >= :start AND inv.arrived_at < :end', range);
.getCount(); if (warehouseId) qb.andWhere('inv.warehouse_id = :warehouseId', { warehouseId });
return await qb.getCount();
} catch { } catch {
return 0; return 0;
} }
} }
async getDashboard(): Promise<WarehouseDashboard> { async getDashboard(filter: WarehouseDashboardFilter = {}): Promise<WarehouseDashboard> {
const warehouseRepo = this.dataSource.getRepository(Warehouse); const warehouseRepo = this.dataSource.getRepository(Warehouse);
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory); const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
const warehouseId = filter.warehouseId || undefined;
const startOfToday = new Date(); const scope = warehouseId ? { warehouseId } : {};
startOfToday.setHours(0, 0, 0, 0); const range = this.resolveRange(filter);
const [ const [
totalWarehouses, totalWarehouses,
@@ -103,23 +127,23 @@ export class WarehouseDashboardService {
dispatched, dispatched,
readyForPickup, readyForPickup,
delivered, delivered,
receivedToday, received,
emptyContainers, emptyContainers,
importTrains, importTrains,
exportTrains, exportTrains,
] = await Promise.all([ ] = await Promise.all([
this.safeCount(warehouseRepo), this.safeCount(warehouseRepo),
this.safeCount(inventoryRepo), this.safeCount(inventoryRepo, { where: { ...scope } }),
this.safeCount(inventoryRepo, { where: { status: 'RECEIVED', inspectionStatus: IsNull() } }), this.safeCount(inventoryRepo, { where: { ...scope, status: 'RECEIVED', inspectionStatus: IsNull() } }),
this.safeCount(inventoryRepo, { where: { inspectionStatus: 'PASSED' } }), this.safeCount(inventoryRepo, { where: { ...scope, inspectionStatus: 'PASSED' } }),
this.safeCount(inventoryRepo, { where: { status: 'STORED' } }), this.safeCount(inventoryRepo, { where: { ...scope, status: 'STORED' } }),
this.safeCount(inventoryRepo, { where: { status: 'RESERVED' } }), this.safeCount(inventoryRepo, { where: { ...scope, status: 'RESERVED' } }),
this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_LOADING' } }), this.safeCount(inventoryRepo, { where: { ...scope, status: 'READY_FOR_LOADING' } }),
this.safeCount(inventoryRepo, { where: { status: 'LOADED' } }), this.safeCount(inventoryRepo, { where: { ...scope, status: 'LOADED' } }),
this.safeCount(inventoryRepo, { where: { status: 'DISPATCHED' } }), this.safeCount(inventoryRepo, { where: { ...scope, status: 'DISPATCHED' } }),
this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_PICKUP' } }), this.safeCount(inventoryRepo, { where: { ...scope, status: 'READY_FOR_PICKUP' } }),
this.safeCount(inventoryRepo, { where: { status: 'DELIVERED' } }), this.safeCount(inventoryRepo, { where: { ...scope, status: 'DELIVERED' } }),
this.safeReceivedToday(startOfToday), this.safeReceived(range, warehouseId),
// ponytail: the container fleet has no literal EMPTY status (AVAILABLE | LOADED | // ponytail: the container fleet has no literal EMPTY status (AVAILABLE | LOADED |
// IN_TRANSIT | MAINTENANCE | DAMAGED) — AVAILABLE (not on a wagon, not in transit, // IN_TRANSIT | MAINTENANCE | DAMAGED) — AVAILABLE (not on a wagon, not in transit,
// not flagged) is the closest proxy for "empty and free to use". Revisit if the // not flagged) is the closest proxy for "empty and free to use". Revisit if the
@@ -135,7 +159,7 @@ export class WarehouseDashboardService {
return { return {
totalWarehouses, totalWarehouses,
totalInventory, totalInventory,
receivedToday, received,
awaitingInspection, awaitingInspection,
inspected, inspected,
stored, stored,

View File

@@ -43,9 +43,20 @@ export class WarehousesController {
@Get('dashboard') @Get('dashboard')
@BookingStaff(FREIGHT_PERMS.warehouseDashboard.view) @BookingStaff(FREIGHT_PERMS.warehouseDashboard.view)
@ApiOperation({ summary: 'Warehouse dashboard metrics' }) @ApiOperation({
dashboard() { summary: 'Warehouse dashboard metrics',
return this.dashboardService.getDashboard(); description:
'dateFrom/dateTo scope only the activity counters (currently just "received"); ' +
'status-backlog and fleet counters are always current. Omit both for "received today" ' +
'(the original default). warehouseId scopes every warehouse_inventory-derived counter; ' +
'totalWarehouses/emptyContainers/importTrains/exportTrains are never warehouse-scoped.',
})
dashboard(
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
@Query('warehouseId') warehouseId?: string,
) {
return this.dashboardService.getDashboard({ dateFrom, dateTo, warehouseId });
} }
@Post() @Post()

View File

@@ -20,6 +20,7 @@ import type {
SaveWarehousePayload, SaveWarehousePayload,
SaveYardPayload, SaveYardPayload,
SaveZonePayload, SaveZonePayload,
WarehouseDashboardFilter,
WarehouseFilter, WarehouseFilter,
} from '@/types/warehouse'; } from '@/types/warehouse';
@@ -461,10 +462,10 @@ export function useInventoryActivity(id?: string) {
}); });
} }
export function useWarehouseDashboard() { export function useWarehouseDashboard(filter?: WarehouseDashboardFilter) {
return useQuery({ return useQuery({
queryKey: ['warehouses', 'dashboard'], queryKey: ['warehouses', 'dashboard', filter ?? {}],
queryFn: () => warehouseService.dashboard().then((r) => r.data), queryFn: () => warehouseService.dashboard(filter).then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS, refetchInterval: DASHBOARD_REFETCH_MS,
}); });
} }

View File

@@ -1,5 +1,7 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Badge, Card, Center, Divider, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core'; import { Badge, Card, Center, Divider, Group, Loader, Select, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { DatePickerInput } from '@mantine/dates';
import { import {
ClipboardList, ClipboardList,
PackageCheck, PackageCheck,
@@ -15,6 +17,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page'; import { PageContainer, PageHeader } from '@/components/page';
import { getDateRangePresets } from '@/components/common/dateRangePresets';
import { import {
AccrualDashboard, AccrualDashboard,
CycleTimeCard, CycleTimeCard,
@@ -24,7 +27,7 @@ import {
WarehouseOpsKpiStrip, WarehouseOpsKpiStrip,
ZoneOccupancyHeatmap, ZoneOccupancyHeatmap,
} from '@/components/warehouses'; } from '@/components/warehouses';
import { useWarehouseDashboard } from '@/hooks/useWarehouses'; import { useWarehouseDashboard, useWarehouses } from '@/hooks/useWarehouses';
import type { WarehouseDashboard } from '@/types/warehouse'; import type { WarehouseDashboard } from '@/types/warehouse';
function SectionTitle({ children }: { children: React.ReactNode }) { function SectionTitle({ children }: { children: React.ReactNode }) {
@@ -50,7 +53,7 @@ const GREEN = '#084b21';
const METRICS: Metric[] = [ const METRICS: Metric[] = [
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE }, { key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory', theme: GREEN }, { key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory', theme: GREEN },
{ key: 'receivedToday', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE }, { key: 'received', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE },
{ key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: GREEN }, { key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: GREEN },
{ key: 'emptyContainers', label: 'Empty Containers', icon: <PackageOpen size={22} />, to: '/dashboard/containers', theme: ORANGE }, { key: 'emptyContainers', label: 'Empty Containers', icon: <PackageOpen size={22} />, to: '/dashboard/containers', theme: ORANGE },
{ key: 'importTrains', label: 'Import Trains', icon: <Train size={22} />, to: '/dashboard/import-warehouse', theme: GREEN }, { key: 'importTrains', label: 'Import Trains', icon: <Train size={22} />, to: '/dashboard/import-warehouse', theme: GREEN },
@@ -64,7 +67,23 @@ const METRICS: Metric[] = [
export default function WarehouseDashboardPage() { export default function WarehouseDashboardPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { data, isError, isLoading } = useWarehouseDashboard(); // Both null → the API defaults `received` to "today", matching the page's original behaviour.
const [dateRange, setDateRange] = useState<[string | null, string | null]>([null, null]);
const [warehouseId, setWarehouseId] = useState<string | null>(null);
const [dateFrom, dateTo] = dateRange;
const hasCustomRange = Boolean(dateFrom || dateTo);
const warehousesQuery = useWarehouses();
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
[warehousesQuery.data],
);
const { data, isError, isLoading } = useWarehouseDashboard({
dateFrom: dateFrom ?? undefined,
dateTo: dateTo ?? undefined,
warehouseId: warehouseId ?? undefined,
});
return ( return (
<PageContainer> <PageContainer>
@@ -72,27 +91,58 @@ export default function WarehouseDashboardPage() {
title="Warehouse Dashboard" title="Warehouse Dashboard"
subtitle="Live overview of warehouse capacity and inventory lifecycle." subtitle="Live overview of warehouse capacity and inventory lifecycle."
action={ action={
<Badge <Group gap="sm" wrap="wrap" justify="flex-end">
color="edr-green" <Select
variant="light" placeholder="All warehouses"
size="lg" clearable
leftSection={ searchable
<span data={warehouseOptions}
style={{ value={warehouseId}
display: 'inline-block', onChange={setWarehouseId}
width: 8, w={220}
height: 8, />
borderRadius: '50%', <DatePickerInput
background: 'var(--mantine-color-edr-green-6)', type="range"
}} placeholder="Received: today"
/> value={dateRange}
} onChange={setDateRange}
> presets={getDateRangePresets()}
Live · updates every 60s clearable
</Badge> w={230}
/>
<Badge
color="edr-green"
variant="light"
size="lg"
leftSection={
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: 'var(--mantine-color-edr-green-6)',
}}
/>
}
>
Live · updates every 60s
</Badge>
</Group>
} }
/> />
{(warehouseId || hasCustomRange) && (
<Text size="xs" c="dimmed" mt={-8}>
Scoped to{' '}
{warehouseId ? warehouseOptions.find((o) => o.value === warehouseId)?.label ?? 'selected warehouse' : 'all warehouses'}
{hasCustomRange
? ` · Received counts ${dateFrom ?? '…'} to ${dateTo ?? '…'}`
: ' · Received counts: today'}
. Status-backlog and fleet counters are always current regardless of the date range.
</Text>
)}
{isLoading ? ( {isLoading ? (
<Center py="xl"> <Center py="xl">
<Loader /> <Loader />
@@ -123,7 +173,7 @@ export default function WarehouseDashboardPage() {
<Group justify="space-between" align="flex-start" wrap="nowrap"> <Group justify="space-between" align="flex-start" wrap="nowrap">
<div> <div>
<Text size="xs" c="edr-muted" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}> <Text size="xs" c="edr-muted" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
{metric.label} {metric.key === 'received' && hasCustomRange ? 'Received' : metric.label}
</Text> </Text>
<Text fw={800} fz={32} mt={8} c="edr-text" lh={1.1}> <Text fw={800} fz={32} mt={8} c="edr-text" lh={1.1}>
{data ? data[metric.key] : 0} {data ? data[metric.key] : 0}

View File

@@ -64,6 +64,7 @@ import type {
Warehouse, Warehouse,
WarehouseActivityLog, WarehouseActivityLog,
WarehouseDashboard, WarehouseDashboard,
WarehouseDashboardFilter,
WarehouseFacility, WarehouseFacility,
WarehouseFilter, WarehouseFilter,
WarehouseInventoryItem, WarehouseInventoryItem,
@@ -245,7 +246,10 @@ export const warehouseService = {
apiClient.get<Warehouse[]>(URL_CONSTANTS.WAREHOUSES.BASE, { apiClient.get<Warehouse[]>(URL_CONSTANTS.WAREHOUSES.BASE, {
params: cleanParams(filter ?? {}), params: cleanParams(filter ?? {}),
}), }),
dashboard: () => apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD), dashboard: (filter?: WarehouseDashboardFilter) =>
apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD, {
params: cleanParams(filter ?? {}),
}),
getDashboardSummary: (_filter?: InventoryFilter) => getDashboardSummary: (_filter?: InventoryFilter) =>
apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD), apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD),
getById: (id: string) => apiClient.get<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id)), getById: (id: string) => apiClient.get<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id)),

View File

@@ -286,10 +286,18 @@ export interface WarehouseActivityLog {
createdAt: string; createdAt: string;
} }
/** Both dates omitted → `received` defaults to "today" (the original behaviour). */
export interface WarehouseDashboardFilter {
dateFrom?: string;
dateTo?: string;
warehouseId?: string;
}
export interface WarehouseDashboard { export interface WarehouseDashboard {
totalWarehouses: number; totalWarehouses: number;
totalInventory: number; totalInventory: number;
receivedToday: number; /** Items received in the requested range — "today" when no range is set. */
received: number;
awaitingInspection: number; awaitingInspection: number;
inspected: number; inspected: number;
stored: number; stored: number;