mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 06:35:42 +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 { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Request, Res } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import type { Response } from 'express';
|
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 { BookingStaff } from '../../common/booking-guards';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { BulkReceiveDto } from './dto/bulk-receive.dto';
|
import { BulkReceiveDto } from './dto/bulk-receive.dto';
|
||||||
@@ -95,6 +98,13 @@ export class WarehouseInventoryController {
|
|||||||
return this.inventoryService.cycleStats();
|
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')
|
@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' })
|
||||||
@@ -120,7 +130,8 @@ export class WarehouseInventoryController {
|
|||||||
@Post('receive-bulk')
|
@Post('receive-bulk')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
||||||
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
|
@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);
|
return this.inventoryService.bulkReceive(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,15 +177,16 @@ export class WarehouseInventoryController {
|
|||||||
loadItemsOntoTrain(
|
loadItemsOntoTrain(
|
||||||
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
|
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
|
||||||
@Body() dto: { inventoryIds: string[]; performedBy?: 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')
|
@Post('bulk-dispatch-export')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
|
||||||
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
|
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
|
||||||
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) {
|
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }, @CurrentUser() user: TCurrentUser) {
|
||||||
return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], dto.performedBy);
|
return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], actorLabel(user) ?? dto.performedBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('bulk-mark-inspected')
|
@Post('bulk-mark-inspected')
|
||||||
@@ -197,8 +209,12 @@ export class WarehouseInventoryController {
|
|||||||
@Post(':id/gate-clearance')
|
@Post(':id/gate-clearance')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.gatePass)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.gatePass)
|
||||||
@ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' })
|
@ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' })
|
||||||
gateClearance(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
gateClearance(
|
||||||
return this.inventoryService.gateClearance(id, performedBy);
|
@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')
|
@Get('import/arrive-queue')
|
||||||
@@ -223,10 +239,10 @@ export class WarehouseInventoryController {
|
|||||||
warehouseId?: string;
|
warehouseId?: string;
|
||||||
performedBy?: string;
|
performedBy?: string;
|
||||||
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
|
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
|
||||||
}) {
|
}, @CurrentUser() user: TCurrentUser) {
|
||||||
return this.inventoryService.autoUnloadArrivedBookings(
|
return this.inventoryService.autoUnloadArrivedBookings(
|
||||||
dto.scheduleId,
|
dto.scheduleId,
|
||||||
dto.performedBy,
|
actorLabel(user) ?? dto.performedBy,
|
||||||
dto.warehouseId,
|
dto.warehouseId,
|
||||||
dto.assignments,
|
dto.assignments,
|
||||||
);
|
);
|
||||||
@@ -268,8 +284,8 @@ export class WarehouseInventoryController {
|
|||||||
@Post('export/auto-unload-at-djibouti')
|
@Post('export/auto-unload-at-djibouti')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
||||||
@ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' })
|
@ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' })
|
||||||
autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }) {
|
autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }, @CurrentUser() user: TCurrentUser) {
|
||||||
return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, dto.performedBy);
|
return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, actorLabel(user) ?? dto.performedBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('import/pickup-ready-queue')
|
@Get('import/pickup-ready-queue')
|
||||||
@@ -296,14 +312,16 @@ export class WarehouseInventoryController {
|
|||||||
@Post('receive')
|
@Post('receive')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
||||||
@ApiOperation({ summary: 'Receive inventory at a warehouse location' })
|
@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);
|
return this.inventoryService.receive(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('reserve')
|
@Post('reserve')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||||
@ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' })
|
@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);
|
return this.inventoryService.reserve(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,15 +356,19 @@ export class WarehouseInventoryController {
|
|||||||
@Post(':id/store')
|
@Post(':id/store')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||||
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
|
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
|
||||||
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) {
|
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto, @CurrentUser() user: TCurrentUser) {
|
||||||
return this.inventoryService.store(id, dto.performedBy, dto);
|
return this.inventoryService.store(id, actorLabel(user) ?? dto.performedBy, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/ready-for-loading')
|
@Post(':id/ready-for-loading')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||||
@ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' })
|
@ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' })
|
||||||
readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
readyForLoading(
|
||||||
return this.inventoryService.readyForLoading(id, performedBy);
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body('performedBy') performedBy: string | undefined,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.inventoryService.readyForLoading(id, actorLabel(user) ?? performedBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/load')
|
@Post(':id/load')
|
||||||
@@ -359,8 +381,12 @@ export class WarehouseInventoryController {
|
|||||||
@Post(':id/ready-for-pickup')
|
@Post(':id/ready-for-pickup')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||||
@ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' })
|
@ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' })
|
||||||
readyForPickup(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
readyForPickup(
|
||||||
return this.inventoryService.readyForPickup(id, performedBy);
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body('performedBy') performedBy: string | undefined,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.inventoryService.readyForPickup(id, actorLabel(user) ?? performedBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/release')
|
@Post(':id/release')
|
||||||
@@ -422,10 +448,11 @@ export class WarehouseInventoryController {
|
|||||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
@Body() dto: ApproveDeliveryDto,
|
@Body() dto: ApproveDeliveryDto,
|
||||||
@Request() req: { user?: { id?: string; sub?: string } },
|
@Request() req: { user?: { id?: string; sub?: string } },
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
return this.inventoryService.approveDeliveryForBooking(
|
return this.inventoryService.approveDeliveryForBooking(
|
||||||
bookingId,
|
bookingId,
|
||||||
req.user?.id ?? req.user?.sub,
|
user?.id ?? req.user?.id ?? req.user?.sub,
|
||||||
dto.signerName,
|
dto.signerName,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -487,14 +514,19 @@ export class WarehouseInventoryController {
|
|||||||
@Post(':id/deliver')
|
@Post(':id/deliver')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver)
|
||||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
@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);
|
return this.inventoryService.deliver(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/dispatch')
|
@Patch(':id/dispatch')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
|
||||||
@ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' })
|
@ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' })
|
||||||
dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
dispatch(
|
||||||
return this.inventoryService.dispatch(id, performedBy);
|
@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
|
* 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
|
* 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 { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import type { Response } from 'express';
|
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 { BookingStaff } from '../../common/booking-guards';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
|
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
|
||||||
@@ -17,7 +20,8 @@ export class WarehouseInvoiceController {
|
|||||||
@Post('warehouse-inventory/:id/generate-fee-invoice')
|
@Post('warehouse-inventory/:id/generate-fee-invoice')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate)
|
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate)
|
||||||
@ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' })
|
@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);
|
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 { AccrualDashboard } from './AccrualDashboard';
|
||||||
export { DwellAgingCard } from './DwellAgingCard';
|
export { DwellAgingCard } from './DwellAgingCard';
|
||||||
export { CycleTimeCard } from './CycleTimeCard';
|
export { CycleTimeCard } from './CycleTimeCard';
|
||||||
|
export { GateThroughputCard } from './GateThroughputCard';
|
||||||
|
|||||||
@@ -494,6 +494,7 @@ export const URL_CONSTANTS = {
|
|||||||
`/warehouse-inventory/throughput?granularity=${granularity}`,
|
`/warehouse-inventory/throughput?granularity=${granularity}`,
|
||||||
DWELL_STATS: "/warehouse-inventory/dwell-stats",
|
DWELL_STATS: "/warehouse-inventory/dwell-stats",
|
||||||
CYCLE_STATS: "/warehouse-inventory/cycle-stats",
|
CYCLE_STATS: "/warehouse-inventory/cycle-stats",
|
||||||
|
GATE_STATS: "/warehouse-inventory/gate-stats",
|
||||||
ZONE_OCCUPANCY: (yardId?: string) =>
|
ZONE_OCCUPANCY: (yardId?: string) =>
|
||||||
yardId
|
yardId
|
||||||
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`
|
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ export function useZoneOccupancy(yardId?: string) {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['warehouse-zones', 'occupancy', yardId ?? 'all'],
|
queryKey: ['warehouse-zones', 'occupancy', yardId ?? 'all'],
|
||||||
queryFn: () => warehouseService.zoneOccupancy(yardId).then((r) => r.data),
|
queryFn: () => warehouseService.zoneOccupancy(yardId).then((r) => r.data),
|
||||||
|
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,14 +150,19 @@ export function useWarehouseOpsStats() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['warehouse-inventory', 'ops-stats'],
|
queryKey: ['warehouse-inventory', 'ops-stats'],
|
||||||
queryFn: () => warehouseService.opsStats().then((r) => r.data),
|
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. */
|
/** Server-side received-vs-dispatched throughput time series. */
|
||||||
export function useWarehouseThroughput(granularity: 'week' | 'month' | 'year') {
|
export function useWarehouseThroughput(granularity: 'week' | 'month' | 'year') {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['warehouse-inventory', 'throughput', granularity],
|
queryKey: ['warehouse-inventory', 'throughput', granularity],
|
||||||
queryFn: () => warehouseService.throughput(granularity).then((r) => r.data),
|
queryFn: () => warehouseService.throughput(granularity).then((r) => r.data),
|
||||||
|
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +171,7 @@ export function useWarehouseDwellStats() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['warehouse-inventory', 'dwell-stats'],
|
queryKey: ['warehouse-inventory', 'dwell-stats'],
|
||||||
queryFn: () => warehouseService.dwellStats().then((r) => r.data),
|
queryFn: () => warehouseService.dwellStats().then((r) => r.data),
|
||||||
|
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +180,16 @@ export function useWarehouseCycleStats() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['warehouse-inventory', 'cycle-stats'],
|
queryKey: ['warehouse-inventory', 'cycle-stats'],
|
||||||
queryFn: () => warehouseService.cycleStats().then((r) => r.data),
|
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({
|
return useQuery({
|
||||||
queryKey: ['warehouse-fees', 'on-time-dispatch'],
|
queryKey: ['warehouse-fees', 'on-time-dispatch'],
|
||||||
queryFn: () => warehouseService.onTimeDispatch().then((r) => r.data),
|
queryFn: () => warehouseService.onTimeDispatch().then((r) => r.data),
|
||||||
|
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +207,7 @@ export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'],
|
queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'],
|
||||||
queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data),
|
queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data),
|
||||||
|
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,6 +456,7 @@ export function useWarehouseDashboard() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['warehouses', 'dashboard'],
|
queryKey: ['warehouses', 'dashboard'],
|
||||||
queryFn: () => warehouseService.dashboard().then((r) => r.data),
|
queryFn: () => warehouseService.dashboard().then((r) => r.data),
|
||||||
|
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
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 {
|
import {
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
AccrualDashboard,
|
AccrualDashboard,
|
||||||
CycleTimeCard,
|
CycleTimeCard,
|
||||||
DwellAgingCard,
|
DwellAgingCard,
|
||||||
|
GateThroughputCard,
|
||||||
WarehouseDashboardCharts,
|
WarehouseDashboardCharts,
|
||||||
WarehouseOpsKpiStrip,
|
WarehouseOpsKpiStrip,
|
||||||
ZoneOccupancyHeatmap,
|
ZoneOccupancyHeatmap,
|
||||||
@@ -71,6 +72,26 @@ export default function WarehouseDashboardPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
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={
|
||||||
|
<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 ? (
|
{isLoading ? (
|
||||||
@@ -129,9 +150,10 @@ export default function WarehouseDashboardPage() {
|
|||||||
|
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<SectionTitle>Performance</SectionTitle>
|
<SectionTitle>Performance</SectionTitle>
|
||||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, lg: 2, xl: 3 }} spacing="md">
|
||||||
<DwellAgingCard />
|
<DwellAgingCard />
|
||||||
<CycleTimeCard />
|
<CycleTimeCard />
|
||||||
|
<GateThroughputCard />
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
WarehouseDwellStats,
|
WarehouseDwellStats,
|
||||||
WarehouseCycleStats,
|
WarehouseCycleStats,
|
||||||
WarehouseOnTimeStats,
|
WarehouseOnTimeStats,
|
||||||
|
WarehouseGateStats,
|
||||||
AccrualDashboardRow,
|
AccrualDashboardRow,
|
||||||
AllocationCriteria,
|
AllocationCriteria,
|
||||||
AllocationPreviewResult,
|
AllocationPreviewResult,
|
||||||
@@ -406,6 +407,8 @@ export const warehouseService = {
|
|||||||
apiClient.get<WarehouseDwellStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DWELL_STATS),
|
apiClient.get<WarehouseDwellStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DWELL_STATS),
|
||||||
cycleStats: () =>
|
cycleStats: () =>
|
||||||
apiClient.get<WarehouseCycleStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.CYCLE_STATS),
|
apiClient.get<WarehouseCycleStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.CYCLE_STATS),
|
||||||
|
gateStats: () =>
|
||||||
|
apiClient.get<WarehouseGateStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.GATE_STATS),
|
||||||
autoUnloadArrived: () =>
|
autoUnloadArrived: () =>
|
||||||
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
|
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
|
||||||
autoLoadReady: () =>
|
autoLoadReady: () =>
|
||||||
|
|||||||
@@ -1149,6 +1149,13 @@ export interface WarehouseOnTimeStats {
|
|||||||
onTimePct: number | null;
|
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';
|
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