mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #709 from Tria-plc/wh-dashboard
Added performed by to warehouse for audit
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
/**
|
||||
* Human-readable actor label for audit stamps (`performed_by` / `moved_by`).
|
||||
* Prefers a display name, then username/email, so the activity log shows a
|
||||
* person rather than a UUID. Returns undefined when there is no authenticated
|
||||
* user (internal/cron calls), letting callers fall back to their prior value.
|
||||
*/
|
||||
export function actorLabel(user?: TCurrentUser | null): string | undefined {
|
||||
if (!user) return undefined;
|
||||
const name = user.name?.en?.trim() || user.name?.am?.trim();
|
||||
return name || user.username || user.email || user.id || undefined;
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Request, Res } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { actorLabel } from './current-actor.util';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { BulkReceiveDto } from './dto/bulk-receive.dto';
|
||||
@@ -95,6 +98,13 @@ export class WarehouseInventoryController {
|
||||
return this.inventoryService.cycleStats();
|
||||
}
|
||||
|
||||
@Get('gate-stats')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Gate/dock throughput: cleared today, turnaround, hourly clearances' })
|
||||
gateStats() {
|
||||
return this.inventoryService.gateStats();
|
||||
}
|
||||
|
||||
@Post('auto-unload-arrived')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
||||
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
|
||||
@@ -120,7 +130,8 @@ export class WarehouseInventoryController {
|
||||
@Post('receive-bulk')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
||||
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
|
||||
receiveBulk(@Body() dto: BulkReceiveDto) {
|
||||
receiveBulk(@Body() dto: BulkReceiveDto, @CurrentUser() user: TCurrentUser) {
|
||||
dto.performedBy = actorLabel(user) ?? dto.performedBy;
|
||||
return this.inventoryService.bulkReceive(dto);
|
||||
}
|
||||
|
||||
@@ -166,15 +177,16 @@ export class WarehouseInventoryController {
|
||||
loadItemsOntoTrain(
|
||||
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
|
||||
@Body() dto: { inventoryIds: string[]; performedBy?: string },
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.inventoryService.loadItemsOntoTrain(scheduleId, dto.inventoryIds ?? [], dto.performedBy);
|
||||
return this.inventoryService.loadItemsOntoTrain(scheduleId, dto.inventoryIds ?? [], actorLabel(user) ?? dto.performedBy);
|
||||
}
|
||||
|
||||
@Post('bulk-dispatch-export')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
|
||||
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
|
||||
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) {
|
||||
return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], dto.performedBy);
|
||||
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }, @CurrentUser() user: TCurrentUser) {
|
||||
return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], actorLabel(user) ?? dto.performedBy);
|
||||
}
|
||||
|
||||
@Post('bulk-mark-inspected')
|
||||
@@ -197,8 +209,12 @@ export class WarehouseInventoryController {
|
||||
@Post(':id/gate-clearance')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.gatePass)
|
||||
@ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' })
|
||||
gateClearance(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.gateClearance(id, performedBy);
|
||||
gateClearance(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('performedBy') performedBy: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.inventoryService.gateClearance(id, actorLabel(user) ?? performedBy);
|
||||
}
|
||||
|
||||
@Get('import/arrive-queue')
|
||||
@@ -223,10 +239,10 @@ export class WarehouseInventoryController {
|
||||
warehouseId?: string;
|
||||
performedBy?: string;
|
||||
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
|
||||
}) {
|
||||
}, @CurrentUser() user: TCurrentUser) {
|
||||
return this.inventoryService.autoUnloadArrivedBookings(
|
||||
dto.scheduleId,
|
||||
dto.performedBy,
|
||||
actorLabel(user) ?? dto.performedBy,
|
||||
dto.warehouseId,
|
||||
dto.assignments,
|
||||
);
|
||||
@@ -268,8 +284,8 @@ export class WarehouseInventoryController {
|
||||
@Post('export/auto-unload-at-djibouti')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
||||
@ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' })
|
||||
autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }) {
|
||||
return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, dto.performedBy);
|
||||
autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }, @CurrentUser() user: TCurrentUser) {
|
||||
return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, actorLabel(user) ?? dto.performedBy);
|
||||
}
|
||||
|
||||
@Get('import/pickup-ready-queue')
|
||||
@@ -296,14 +312,16 @@ export class WarehouseInventoryController {
|
||||
@Post('receive')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
||||
@ApiOperation({ summary: 'Receive inventory at a warehouse location' })
|
||||
receive(@Body() dto: ReceiveWarehouseInventoryDto) {
|
||||
receive(@Body() dto: ReceiveWarehouseInventoryDto, @CurrentUser() user: TCurrentUser) {
|
||||
dto.performedBy = actorLabel(user) ?? dto.performedBy;
|
||||
return this.inventoryService.receive(dto);
|
||||
}
|
||||
|
||||
@Post('reserve')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' })
|
||||
reserve(@Body() dto: ReserveInventoryDto) {
|
||||
reserve(@Body() dto: ReserveInventoryDto, @CurrentUser() user: TCurrentUser) {
|
||||
dto.performedBy = actorLabel(user) ?? dto.performedBy;
|
||||
return this.inventoryService.reserve(dto);
|
||||
}
|
||||
|
||||
@@ -338,15 +356,19 @@ export class WarehouseInventoryController {
|
||||
@Post(':id/store')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
|
||||
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) {
|
||||
return this.inventoryService.store(id, dto.performedBy, dto);
|
||||
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto, @CurrentUser() user: TCurrentUser) {
|
||||
return this.inventoryService.store(id, actorLabel(user) ?? dto.performedBy, dto);
|
||||
}
|
||||
|
||||
@Post(':id/ready-for-loading')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' })
|
||||
readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.readyForLoading(id, performedBy);
|
||||
readyForLoading(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('performedBy') performedBy: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.inventoryService.readyForLoading(id, actorLabel(user) ?? performedBy);
|
||||
}
|
||||
|
||||
@Post(':id/load')
|
||||
@@ -359,8 +381,12 @@ export class WarehouseInventoryController {
|
||||
@Post(':id/ready-for-pickup')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' })
|
||||
readyForPickup(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.readyForPickup(id, performedBy);
|
||||
readyForPickup(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('performedBy') performedBy: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.inventoryService.readyForPickup(id, actorLabel(user) ?? performedBy);
|
||||
}
|
||||
|
||||
@Post(':id/release')
|
||||
@@ -422,10 +448,11 @@ export class WarehouseInventoryController {
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: ApproveDeliveryDto,
|
||||
@Request() req: { user?: { id?: string; sub?: string } },
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.inventoryService.approveDeliveryForBooking(
|
||||
bookingId,
|
||||
req.user?.id ?? req.user?.sub,
|
||||
user?.id ?? req.user?.id ?? req.user?.sub,
|
||||
dto.signerName,
|
||||
);
|
||||
}
|
||||
@@ -487,14 +514,19 @@ export class WarehouseInventoryController {
|
||||
@Post(':id/deliver')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver)
|
||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto, @CurrentUser() user: TCurrentUser) {
|
||||
dto.performedBy = actorLabel(user) ?? dto.performedBy;
|
||||
return this.inventoryService.deliver(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/dispatch')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
|
||||
@ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' })
|
||||
dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.dispatch(id, performedBy);
|
||||
dispatch(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('performedBy') performedBy: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.inventoryService.dispatch(id, actorLabel(user) ?? performedBy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,6 +537,58 @@ export class WarehouseInventoryService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate / dock throughput: items cleared through the gate today, the average
|
||||
* arrival→gate-clearance turnaround (hours, last 30 days), and gate clearances
|
||||
* bucketed per hour over the last 24 hours. Powers the gate throughput card.
|
||||
*/
|
||||
async gateStats(): Promise<{
|
||||
clearedToday: number;
|
||||
avgTurnaroundHours: number | null;
|
||||
byHour: Array<{ hour: string; count: number }>;
|
||||
}> {
|
||||
const [scalar]: Array<{ clearedToday: number; avgTurnaroundHours: number | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT
|
||||
count(*) FILTER (WHERE gate_cleared_at::date = CURRENT_DATE)::int AS "clearedToday",
|
||||
round(
|
||||
avg(EXTRACT(EPOCH FROM (gate_cleared_at - arrived_at)) / 3600.0)
|
||||
FILTER (
|
||||
WHERE gate_cleared_at IS NOT NULL AND arrived_at IS NOT NULL
|
||||
AND gate_cleared_at > now() - interval '30 days'
|
||||
)::numeric,
|
||||
1
|
||||
)::float8 AS "avgTurnaroundHours"
|
||||
FROM freight.warehouse_inventory
|
||||
WHERE deleted_at IS NULL`,
|
||||
);
|
||||
const byHour: Array<{ hour: string; count: number }> = await this.dataSource.query(
|
||||
`WITH hours AS (
|
||||
SELECT gs AS h
|
||||
FROM generate_series(
|
||||
date_trunc('hour', now()) - interval '23 hours',
|
||||
date_trunc('hour', now()),
|
||||
interval '1 hour'
|
||||
) gs
|
||||
)
|
||||
SELECT to_char(hours.h, 'HH24:00') AS hour,
|
||||
COALESCE(g.cnt, 0)::int AS count
|
||||
FROM hours
|
||||
LEFT JOIN (
|
||||
SELECT date_trunc('hour', gate_cleared_at) AS ph, count(*) AS cnt
|
||||
FROM freight.warehouse_inventory
|
||||
WHERE deleted_at IS NULL AND gate_cleared_at IS NOT NULL
|
||||
GROUP BY 1
|
||||
) g ON g.ph = hours.h
|
||||
ORDER BY hours.h`,
|
||||
);
|
||||
return {
|
||||
clearedToday: scalar?.clearedToday ?? 0,
|
||||
avgTurnaroundHours: scalar?.avgTurnaroundHours ?? null,
|
||||
byHour,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { actorLabel } from './current-actor.util';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
|
||||
@@ -17,7 +20,8 @@ export class WarehouseInvoiceController {
|
||||
@Post('warehouse-inventory/:id/generate-fee-invoice')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate)
|
||||
@ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' })
|
||||
generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto) {
|
||||
generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto, @CurrentUser() user: TCurrentUser) {
|
||||
dto.performedBy = actorLabel(user) ?? dto.performedBy;
|
||||
return this.invoiceService.generateForInventory(id, dto);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Card, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import { DoorOpen } from 'lucide-react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { useWarehouseGateStats } from '@/hooks/useWarehouses';
|
||||
|
||||
/**
|
||||
* Gate / dock throughput: items cleared through the gate today, the average
|
||||
* arrival→gate-clearance turnaround, and clearances per hour over the last 24h.
|
||||
*/
|
||||
export function GateThroughputCard() {
|
||||
const { data, isLoading } = useWarehouseGateStats();
|
||||
const byHour = data?.byHour ?? [];
|
||||
const hasActivity = byHour.some((h) => h.count > 0);
|
||||
|
||||
return (
|
||||
<Card withBorder radius="lg" padding="lg" h="100%">
|
||||
<Group gap="sm" mb="md">
|
||||
<ThemeIcon size="lg" radius="md" color="indigo" variant="light">
|
||||
<DoorOpen size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Gate & dock throughput</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Gate clearances over the last 24 hours
|
||||
</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}>
|
||||
Cleared today
|
||||
</Text>
|
||||
<Text fw={800} fz={30} lh={1.1}>
|
||||
{data?.clearedToday ?? 0}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
through the gate
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
Avg turnaround
|
||||
</Text>
|
||||
<Text fw={800} fz={30} lh={1.1}>
|
||||
{data?.avgTurnaroundHours == null ? '—' : `${data.avgTurnaroundHours} h`}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
arrival → gate (30d)
|
||||
</Text>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
|
||||
{hasActivity ? (
|
||||
<ResponsiveContainer width="100%" height={170}>
|
||||
<BarChart data={byHour} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--mantine-color-gray-2)" />
|
||||
<XAxis dataKey="hour" interval={3} tick={{ fontSize: 11 }} />
|
||||
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
|
||||
<Tooltip cursor={{ fill: 'var(--mantine-color-gray-1)' }} />
|
||||
<Bar dataKey="count" name="Cleared" fill="#4c6ef5" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Group justify="center" align="center" h={170}>
|
||||
<Text c="dimmed" size="sm">
|
||||
No gate clearances in the last 24 hours.
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -34,3 +34,4 @@ export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip';
|
||||
export { AccrualDashboard } from './AccrualDashboard';
|
||||
export { DwellAgingCard } from './DwellAgingCard';
|
||||
export { CycleTimeCard } from './CycleTimeCard';
|
||||
export { GateThroughputCard } from './GateThroughputCard';
|
||||
|
||||
@@ -494,6 +494,7 @@ export const URL_CONSTANTS = {
|
||||
`/warehouse-inventory/throughput?granularity=${granularity}`,
|
||||
DWELL_STATS: "/warehouse-inventory/dwell-stats",
|
||||
CYCLE_STATS: "/warehouse-inventory/cycle-stats",
|
||||
GATE_STATS: "/warehouse-inventory/gate-stats",
|
||||
ZONE_OCCUPANCY: (yardId?: string) =>
|
||||
yardId
|
||||
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`
|
||||
|
||||
@@ -141,6 +141,7 @@ export function useZoneOccupancy(yardId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-zones', 'occupancy', yardId ?? 'all'],
|
||||
queryFn: () => warehouseService.zoneOccupancy(yardId).then((r) => r.data),
|
||||
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -149,14 +150,19 @@ export function useWarehouseOpsStats() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'ops-stats'],
|
||||
queryFn: () => warehouseService.opsStats().then((r) => r.data),
|
||||
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||
});
|
||||
}
|
||||
|
||||
/** How often the live warehouse dashboard widgets auto-refresh (ms). */
|
||||
export const DASHBOARD_REFETCH_MS = 60_000;
|
||||
|
||||
/** 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),
|
||||
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -165,6 +171,7 @@ export function useWarehouseDwellStats() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'dwell-stats'],
|
||||
queryFn: () => warehouseService.dwellStats().then((r) => r.data),
|
||||
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -173,6 +180,16 @@ export function useWarehouseCycleStats() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'cycle-stats'],
|
||||
queryFn: () => warehouseService.cycleStats().then((r) => r.data),
|
||||
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||
});
|
||||
}
|
||||
|
||||
/** Gate / dock throughput (cleared today, turnaround, hourly clearances). */
|
||||
export function useWarehouseGateStats() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'gate-stats'],
|
||||
queryFn: () => warehouseService.gateStats().then((r) => r.data),
|
||||
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -181,6 +198,7 @@ export function useOnTimeDispatch() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-fees', 'on-time-dispatch'],
|
||||
queryFn: () => warehouseService.onTimeDispatch().then((r) => r.data),
|
||||
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -189,6 +207,7 @@ export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'],
|
||||
queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data),
|
||||
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -437,6 +456,7 @@ export function useWarehouseDashboard() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouses', 'dashboard'],
|
||||
queryFn: () => warehouseService.dashboard().then((r) => r.data),
|
||||
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, Center, Divider, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import { Badge, Card, Center, Divider, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import {
|
||||
ClipboardCheck,
|
||||
ClipboardList,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
AccrualDashboard,
|
||||
CycleTimeCard,
|
||||
DwellAgingCard,
|
||||
GateThroughputCard,
|
||||
WarehouseDashboardCharts,
|
||||
WarehouseOpsKpiStrip,
|
||||
ZoneOccupancyHeatmap,
|
||||
@@ -71,6 +72,26 @@ export default function WarehouseDashboardPage() {
|
||||
<PageHeader
|
||||
title="Warehouse Dashboard"
|
||||
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
||||
action={
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -129,9 +150,10 @@ export default function WarehouseDashboardPage() {
|
||||
|
||||
<Stack gap="sm">
|
||||
<SectionTitle>Performance</SectionTitle>
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
<SimpleGrid cols={{ base: 1, lg: 2, xl: 3 }} spacing="md">
|
||||
<DwellAgingCard />
|
||||
<CycleTimeCard />
|
||||
<GateThroughputCard />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
WarehouseDwellStats,
|
||||
WarehouseCycleStats,
|
||||
WarehouseOnTimeStats,
|
||||
WarehouseGateStats,
|
||||
AccrualDashboardRow,
|
||||
AllocationCriteria,
|
||||
AllocationPreviewResult,
|
||||
@@ -406,6 +407,8 @@ export const warehouseService = {
|
||||
apiClient.get<WarehouseDwellStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DWELL_STATS),
|
||||
cycleStats: () =>
|
||||
apiClient.get<WarehouseCycleStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.CYCLE_STATS),
|
||||
gateStats: () =>
|
||||
apiClient.get<WarehouseGateStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.GATE_STATS),
|
||||
autoUnloadArrived: () =>
|
||||
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
|
||||
autoLoadReady: () =>
|
||||
|
||||
@@ -1149,6 +1149,13 @@ export interface WarehouseOnTimeStats {
|
||||
onTimePct: number | null;
|
||||
}
|
||||
|
||||
/** Gate / dock throughput: cleared today, turnaround, and hourly clearances. */
|
||||
export interface WarehouseGateStats {
|
||||
clearedToday: number;
|
||||
avgTurnaroundHours: number | null;
|
||||
byHour: Array<{ hour: string; count: number }>;
|
||||
}
|
||||
|
||||
export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
|
||||
|
||||
/** One item's live fee accrual for the accrual dashboard. */
|
||||
|
||||
Reference in New Issue
Block a user