From a542a1389396eff4a092f281e845fe4fd9275d7e Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 13 Jul 2026 13:38:28 +0000 Subject: [PATCH 1/4] wAREHOUSE kPI strips --- .../warehouse-inventory.controller.ts | 6 +++ .../warehouses/warehouse-inventory.service.ts | 39 ++++++++++++++++ .../warehouses/WarehouseOpsKpiStrip.tsx | 45 +++++++++++++++++++ .../src/components/warehouses/index.ts | 1 + .../backoffice/src/constants/URLS.ts | 1 + .../backoffice/src/hooks/useWarehouses.ts | 8 ++++ .../src/pages/warehouses/ArrivalQueuePage.tsx | 3 ++ .../ExportDjiboutiUnloadingQueuePage.tsx | 3 ++ .../src/pages/warehouses/LoadingQueuePage.tsx | 3 ++ .../src/services/warehouse.service.ts | 3 ++ .../backoffice/src/types/warehouse.ts | 8 ++++ 11 files changed, 120 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseOpsKpiStrip.tsx diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 21f2663b6..dcd32a10a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -52,6 +52,12 @@ export class WarehouseInventoryController { return this.inventoryService.arrivalQueue(); } + @Get('ops-stats') + @ApiOperation({ summary: 'At-a-glance warehouse ops counters for the KPI strip' }) + opsStats() { + return this.inventoryService.opsStats(); + } + @Get('zone-occupancy') @ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' }) zoneOccupancy(@Query('yardId') yardId?: string) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 7c8edcce0..a96b3f804 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -397,6 +397,45 @@ export class WarehouseInventoryService { * but has no customer truck assigned yet, nudge the customer to assign one — with * a deep-link to the booking's truck-assignment card. Fire-and-forget. */ + /** + * At-a-glance warehouse ops counters for the KPI strip: + * - receivedToday: items received today + * - pendingInspection: RECEIVED items not yet inspected + * - trucksOnSite: customer trucks arrived but not departed + * - itemsAging: in-warehouse items older than 7 days (demurrage risk) + */ + async opsStats(): Promise<{ + receivedToday: number; + pendingInspection: number; + trucksOnSite: number; + itemsAging: number; + }> { + const [row]: Array<{ + receivedToday: number; + pendingInspection: number; + trucksOnSite: number; + itemsAging: number; + }> = await this.dataSource.query( + `SELECT + (SELECT count(*)::int FROM freight.warehouse_inventory + WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE) AS "receivedToday", + (SELECT count(*)::int FROM freight.warehouse_inventory + WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection", + (SELECT count(*)::int FROM freight.customer_truck_assignments + WHERE deleted_at IS NULL AND arrived_at IS NOT NULL AND departed_at IS NULL) AS "trucksOnSite", + (SELECT count(*)::int FROM freight.warehouse_inventory + WHERE deleted_at IS NULL + AND status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED') + AND created_at < now() - interval '7 days') AS "itemsAging"`, + ); + return { + receivedToday: row?.receivedToday ?? 0, + pendingInspection: row?.pendingInspection ?? 0, + trucksOnSite: row?.trucksOnSite ?? 0, + itemsAging: row?.itemsAging ?? 0, + }; + } + /** * Live occupancy per zone: rated capacity vs the weight/items currently held * (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseOpsKpiStrip.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseOpsKpiStrip.tsx new file mode 100644 index 000000000..cf8e34343 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseOpsKpiStrip.tsx @@ -0,0 +1,45 @@ +import { AlertTriangle, ClipboardCheck, PackageCheck, Truck } from "lucide-react"; + +import { KpiStrip } from "@/components/page"; +import { useWarehouseOpsStats } from "@/hooks/useWarehouses"; + +/** + * At-a-glance warehouse ops KPIs (received today, pending inspection, trucks + * on-site, items aging). Drop-in for any warehouse ops page header. + */ +export function WarehouseOpsKpiStrip() { + const { data, isLoading } = useWarehouseOpsStats(); + + return ( + 7d)", + value: data?.itemsAging ?? 0, + icon: AlertTriangle, + color: (data?.itemsAging ?? 0) > 0 ? "red" : "edr-green", + hint: "In warehouse over 7 days", + }, + ]} + /> + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts index 075237198..da20d6fa7 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts @@ -30,3 +30,4 @@ export { WarehouseDashboardCharts } from './WarehouseDashboardCharts'; export { InspectionReportModal } from './InspectionReportModal'; export { FeePreviewModal } from './FeePreviewModal'; export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap'; +export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip'; diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 2eaee8090..f76dbbde2 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -486,6 +486,7 @@ export const URL_CONSTANTS = { MOVE: (id: string) => `/warehouse-inventory/${id}/move`, RESERVE: "/warehouse-inventory/reserve", ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue", + OPS_STATS: "/warehouse-inventory/ops-stats", ZONE_OCCUPANCY: (yardId?: string) => yardId ? `/warehouse-inventory/zone-occupancy?yardId=${yardId}` diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts index 59709d2d7..da1bfb867 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -144,6 +144,14 @@ export function useZoneOccupancy(yardId?: string) { }); } +/** At-a-glance warehouse ops counters for the KPI strip. */ +export function useWarehouseOpsStats() { + return useQuery({ + queryKey: ['warehouse-inventory', 'ops-stats'], + queryFn: () => warehouseService.opsStats().then((r) => r.data), + }); +} + export function useCreateZone() { const qc = useQueryClient(); return useMutation({ diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx index 821589271..61afa426c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx @@ -15,6 +15,7 @@ import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react'; import { PageContainer, PageHeader } from '@/components/page'; import { VisualEmptyState, + WarehouseOpsKpiStrip, formatDate, formatNumber, } from '@/components/warehouses'; @@ -291,6 +292,8 @@ export default function ArrivalQueuePage() { breadcrumbs={[{ label: 'Arrival queue' }]} /> + + diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx index 6e694ed1e..735026fa7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx @@ -29,6 +29,7 @@ import { ActivityTimeline, InventoryMovementHistoryTable, VisualEmptyState, + WarehouseOpsKpiStrip, formatDate, formatNumber, } from '@/components/warehouses'; @@ -292,6 +293,8 @@ export default function ExportDjiboutiUnloadingQueuePage() { breadcrumbs={[{ label: 'Djibouti Arrival / Unloading Queue' }]} /> + + {trains.length} arrived export train(s) diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx index e9bb3a162..a03c7038e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx @@ -8,6 +8,7 @@ import { PageContainer, PageHeader } from '@/components/page'; import { InventoryWorkbench, VisualEmptyState, + WarehouseOpsKpiStrip, formatNumber, } from '@/components/warehouses'; import { LoadToTrainPanel } from '@/components/warehouses/LoadToTrainPanel'; @@ -74,6 +75,8 @@ export default function LoadingQueuePage() { } /> + + diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index ea0baa32e..08aa01476 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -5,6 +5,7 @@ import { api as apiClient } from '../auth/http'; import { URL_CONSTANTS } from '@/constants/URLS'; import type { ZoneOccupancy, + WarehouseOpsStats, AllocationCriteria, AllocationPreviewResult, AllocationRule, @@ -370,6 +371,8 @@ export const warehouseService = { apiClient.get( URL_CONSTANTS.WAREHOUSE_INVENTORY.ZONE_OCCUPANCY(yardId), ), + opsStats: () => + apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.OPS_STATS), autoUnloadArrived: () => apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED), autoLoadReady: () => diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index 4023bf986..eb04c39bc 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -1107,3 +1107,11 @@ export interface ZoneOccupancy { /** 0–100+, container-count based (weight is a rough fallback). Null if no capacity set. */ occupancyPct: number | null; } + +/** At-a-glance warehouse ops counters for the KPI strip. */ +export interface WarehouseOpsStats { + receivedToday: number; + pendingInspection: number; + trucksOnSite: number; + itemsAging: number; +} From d9fe79e16021772bd20072c7afd17896e76ace2d Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 14 Jul 2026 08:07:46 +0000 Subject: [PATCH 2/4] accrual dashboard for port and terminal or warehouse related documents --- .../warehouses/warehouse-fee.service.ts | 184 +++++++++++++++++- .../warehouse-inventory.controller.ts | 20 ++ .../warehouses/warehouse-inventory.service.ts | 25 +++ .../warehouses/warehouse-rules.controller.ts | 6 + .../warehouses/AccrualDashboard.tsx | 167 ++++++++++++++++ .../warehouses/InventoryWorkbench.tsx | 39 +++- .../warehouses/WarehouseInventoryTable.tsx | 11 +- .../src/components/warehouses/index.ts | 1 + .../src/components/warehouses/pdf.ts | 13 ++ .../backoffice/src/constants/URLS.ts | 1 + .../backoffice/src/hooks/useWarehouses.ts | 8 + .../warehouses/WarehouseInvoicesPage.tsx | 8 + .../src/services/warehouse.service.ts | 5 + .../backoffice/src/types/warehouse.ts | 27 +++ .../portal/src/services/bookings.service.ts | 14 ++ 15 files changed, 526 insertions(+), 3 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index 14a3375c7..720f30766 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -1,7 +1,10 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; import { ExchangeService } from '@edr/api-common'; +import { NotificationAudience, NotificationType } from '@edr/types'; import { DataSource } from 'typeorm'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; @@ -26,6 +29,32 @@ interface ItemAttributes { zoneId: string | null; } +export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING'; + +export interface AccrualDashboardRow { + inventoryId: string; + status: string; + bookingId: string | null; + companyId: string | null; + bookingReference: string | null; + customerName: string | null; + warehouseCode: string | null; + zoneCode: string | null; + receivedAt: string | null; + currency: string; + accruedAmount: number; + freeDaysLeft: number | null; + charging: boolean; + alert: AccrualAlert; + breakdown: Array<{ + type: FeeRuleType; + amount: number; + freeDays: number; + elapsedDays: number; + chargeableDays: number; + }>; +} + export interface FeePreview { ruleType: FeeRuleType; /** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */ @@ -70,12 +99,80 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000; @Injectable() export class WarehouseFeeService { + private readonly logger = new Logger(WarehouseFeeService.name); + constructor( private readonly dataSource: DataSource, private readonly feeRuleRepository: WarehouseFeeRuleRepository, private readonly exchangeService: ExchangeService, + private readonly inbox: NotificationInboxService, ) {} + /** + * Daily accrual alerts: for every in-warehouse item that is charging or within + * its last free days, send the customer an in-app notification with the + * outstanding accrued amount so they can collect before (more) charges hit. + */ + @Cron(CronExpression.EVERY_DAY_AT_6AM, { name: 'warehouse-accrual-alert' }) + async sendAccrualAlerts(): Promise { + try { + const alerts = (await this.accrualDashboard()).filter((r) => r.alert !== 'OK'); + if (!alerts.length) return; + this.logger.log(`Accrual alerts: ${alerts.length} item(s) charging or nearing charges`); + + // Per-customer: notify each company about its own items. + for (const row of alerts.filter((r) => r.companyId)) { + const ref = row.bookingReference ?? row.inventoryId.slice(0, 8); + const amount = `${row.accruedAmount.toFixed(2)} ${row.currency}`; + const body = row.charging + ? `Storage/demurrage is now charging on booking ${ref} — ${amount} accrued. Collect the cargo to stop further charges.` + : `Booking ${ref} has ${row.freeDaysLeft ?? 0} free day(s) left before storage/demurrage charges start (${amount} accrued so far).`; + try { + await this.inbox.notify({ + recipients: { companyId: row.companyId! }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: row.charging ? 'Storage charges accruing' : 'Free days ending soon', + body, + link: row.bookingId ? `/bookings/${row.bookingId}` : undefined, + data: { + inventoryId: row.inventoryId, + bookingId: row.bookingId, + alert: row.alert, + accruedAmount: row.accruedAmount, + action: 'ACCRUAL_ALERT', + }, + }); + } catch (err) { + this.logger.warn( + `Accrual alert failed for ${row.inventoryId}: ${(err as Error).message}`, + ); + } + } + + // Ops staff: one digest covering every alerting item. + const charging = alerts.filter((r) => r.charging).length; + const nearing = alerts.length - charging; + const currency = alerts[0]?.currency ?? 'USD'; + const total = alerts.reduce((sum, r) => sum + r.accruedAmount, 0); + try { + await this.inbox.notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.BOOKING_STATUS, + title: 'Warehouse fee accruals need attention', + body: `${charging} item(s) charging, ${nearing} nearing the free-day limit — ${total.toFixed(2)} ${currency} accruing. Review the accrual dashboard.`, + link: '/dashboard/warehouse-fee-invoices', + data: { charging, nearing, totalAccrued: Math.round(total * 100) / 100, action: 'ACCRUAL_ALERT_DIGEST' }, + }); + } catch (err) { + this.logger.warn(`Accrual staff digest failed: ${(err as Error).message}`); + } + } catch (err) { + this.logger.warn(`Accrual alert tick failed: ${(err as Error).message}`); + } + } + // ── Rule CRUD ────────────────────────────────────────────────────────────── listRules(): Promise { return this.feeRuleRepository.findAll({ order: { ruleType: 'ASC', priority: 'ASC' } }); @@ -416,6 +513,91 @@ export class WarehouseFeeService { }; } + /** + * Live accrual dashboard: for every item still in the warehouse, the fees + * accruing right now (storage + demurrage + double-handling), how many free + * days remain, and an alert level so staff can act before charges land. + */ + async accrualDashboard(billingCurrency = 'USD'): Promise { + const items: Array<{ + id: string; + status: string; + bookingId: string | null; + companyId: string | null; + bookingReference: string | null; + customerName: string | null; + warehouseCode: string | null; + zoneCode: string | null; + receivedAt: string | null; + }> = await this.dataSource.query( + `SELECT inv.id, + inv.status, + b.id AS "bookingId", + b.company_id AS "companyId", + b.reference AS "bookingReference", + c.name AS "customerName", + w.code AS "warehouseCode", + z.code AS "zoneCode", + inv.created_at AS "receivedAt" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies c ON c.id = b.company_id + LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id + LEFT JOIN freight.warehouse_zones z ON z.id = inv.zone_id + WHERE inv.deleted_at IS NULL + AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED') + ORDER BY inv.created_at ASC`, + ); + + const rows = await Promise.all( + items.map(async (it): Promise => { + const previews = (await this.previewForInventory(it.id, billingCurrency)).filter( + (p) => p.ruleId, + ); + const accruedAmount = + Math.round(previews.reduce((sum, p) => sum + (p.amount ?? 0), 0) * 100) / 100; + const charging = previews.some((p) => p.chargeableDays > 0); + const freeDaysLeftVals = previews + .filter((p) => p.endIsOpen) + .map((p) => Math.max(0, p.freeDays - p.elapsedDays)); + const freeDaysLeft = freeDaysLeftVals.length ? Math.min(...freeDaysLeftVals) : null; + const alert: AccrualAlert = charging + ? 'CHARGING' + : freeDaysLeft != null && freeDaysLeft <= 2 + ? 'WARNING' + : 'OK'; + return { + inventoryId: it.id, + status: it.status, + bookingId: it.bookingId, + companyId: it.companyId, + bookingReference: it.bookingReference, + customerName: it.customerName, + warehouseCode: it.warehouseCode, + zoneCode: it.zoneCode, + receivedAt: it.receivedAt, + currency: billingCurrency, + accruedAmount, + freeDaysLeft, + charging, + alert, + breakdown: previews.map((p) => ({ + type: p.ruleType, + amount: p.amount, + freeDays: p.freeDays, + elapsedDays: p.elapsedDays, + chargeableDays: p.chargeableDays, + })), + }; + }), + ); + + const rank = (a: AccrualAlert) => (a === 'CHARGING' ? 0 : a === 'WARNING' ? 1 : 2); + return rows.sort( + (a, b) => rank(a.alert) - rank(b.alert) || b.accruedAmount - a.accruedAmount, + ); + } + /** Preview demurrage + storage fees for an inventory item using the most specific active rules. */ async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise { const item = await this.loadItem(inventoryId); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index dcd32a10a..f45abe507 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -368,6 +368,26 @@ export class WarehouseInventoryController { return this.handoverService.requestSignature(bookingId); } + @Get('bookings/:bookingId/grn-document') + @ApiOperation({ summary: 'View GRN PDF for a booking (customer portal)' }) + async bookingGrnDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { + const { filename, buffer } = await this.inventoryService.grnDocumentForBooking(bookingId); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + + @Get('bookings/:bookingId/release-document') + @ApiOperation({ summary: 'View gate-clearance / release-order PDF for a booking (customer portal)' }) + async bookingReleaseDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { + const { filename, buffer } = await this.inventoryService.releaseDocumentForBooking(bookingId); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + @Get('bookings/:bookingId/handover-document') @ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' }) async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index a96b3f804..8bce58f01 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -3265,6 +3265,31 @@ export class WarehouseInventoryService { return this.handoverDocument(inv.id); } + /** Resolve the primary warehouse-inventory item for a booking (most recent). */ + private async primaryInventoryIdForBooking(bookingId: string): Promise { + const [inv]: Array<{ id: string }> = await this.dataSource.query( + `SELECT id FROM freight.warehouse_inventory + WHERE booking_id = $1 AND deleted_at IS NULL + ORDER BY updated_at DESC NULLS LAST, created_at DESC + LIMIT 1`, + [bookingId], + ); + if (!inv) { + throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`); + } + return inv.id; + } + + /** Booking-scoped GRN document (customer portal). */ + async grnDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { + return this.grnDocument(await this.primaryInventoryIdForBooking(bookingId)); + } + + /** Booking-scoped gate-clearance / release document (customer portal). */ + async releaseDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { + return this.releaseDocument(await this.primaryInventoryIdForBooking(bookingId)); + } + async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( `SELECT inv.id, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts index d35587d9e..1fecf9392 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts @@ -77,6 +77,12 @@ export class WarehouseRulesController { return this.feeService.deleteRule(id); } + @Get('warehouse-fees/accrual-dashboard') + @ApiOperation({ summary: 'Live per-item fee accrual (storage/demurrage) with alerts' }) + accrualDashboard(@Query('billingCurrency') billingCurrency?: string) { + return this.feeService.accrualDashboard(billingCurrency); + } + @Get('warehouse-inventory/:id/fee-preview') @ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' }) feePreview( diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx new file mode 100644 index 000000000..2ac3fa2f9 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx @@ -0,0 +1,167 @@ +import { useMemo } from 'react'; +import { Badge, Card, Group, Loader, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core'; +import { AlertTriangle, Clock, DollarSign } from 'lucide-react'; + +import { useAccrualDashboard } from '@/hooks/useWarehouses'; +import type { AccrualAlert, AccrualDashboardRow } from '@/types/warehouse'; + +const ALERT_META: Record = { + CHARGING: { color: 'red', label: 'Charging' }, + WARNING: { color: 'orange', label: 'Free days ending' }, + OK: { color: 'teal', label: 'Within free days' }, +}; + +function money(amount: number, currency: string): string { + return `${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`; +} + +function freeDaysLabel(row: AccrualDashboardRow): string { + if (row.charging) return 'charging now'; + if (row.freeDaysLeft == null) return '—'; + return `${row.freeDaysLeft} day${row.freeDaysLeft === 1 ? '' : 's'} left`; +} + +/** + * Live accrual dashboard: storage / demurrage ticking per in-warehouse item, + * sorted so items already charging (or about to) surface first. Read-only. + */ +export function AccrualDashboard() { + const { data: rows = [], isLoading } = useAccrualDashboard(); + + const summary = useMemo(() => { + const currency = rows[0]?.currency ?? 'USD'; + return { + currency, + charging: rows.filter((r) => r.alert === 'CHARGING').length, + atRisk: rows.filter((r) => r.alert === 'WARNING').length, + totalAccruing: Math.round(rows.reduce((s, r) => s + r.accruedAmount, 0) * 100) / 100, + }; + }, [rows]); + + if (isLoading) { + return ( + + + + ); + } + + return ( + + + } + label="Accruing now" + value={money(summary.totalAccruing, summary.currency)} + color="edr-green" + /> + } + label="Charging" + value={summary.charging} + color={summary.charging > 0 ? 'red' : 'gray'} + /> + } + label="Free days ending (≤2d)" + value={summary.atRisk} + color={summary.atRisk > 0 ? 'orange' : 'gray'} + /> + + + + {rows.length === 0 ? ( + + No in-warehouse items are accruing fees. + + ) : ( + + + + + Booking + Customer + Location + Status + Accrued + Free days + Alert + + + + {rows.map((row) => { + const meta = ALERT_META[row.alert]; + return ( + + + + {row.bookingReference ?? row.inventoryId.slice(0, 8)} + + + {row.customerName ?? '—'} + + + {[row.warehouseCode, row.zoneCode].filter(Boolean).join(' · ') || '—'} + + + + + {row.status} + + + + 0 ? 'red' : undefined}> + {money(row.accruedAmount, row.currency)} + + + + + {freeDaysLabel(row)} + + + + + {meta.label} + + + + ); + })} + +
+
+ )} +
+
+ ); +} + +function StatCard({ + icon, + label, + value, + color, +}: { + icon: React.ReactNode; + label: string; + value: React.ReactNode; + color: string; +}) { + return ( + + + + {icon} + + + + {label} + + + {value} + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx index c4f1f5cec..d8b370de0 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx @@ -19,7 +19,7 @@ import { MoveInventoryModal } from './MoveInventoryModal'; import { ReleaseOrderModal } from './ReleaseOrderModal'; import { WarehouseInventoryTable } from './WarehouseInventoryTable'; import { extractDownloadErrorMessage, extractErrorMessage } from './options'; -import { openPdfBlob } from './pdf'; +import { openPdfBlob, saveBlob } from './pdf'; interface InventoryWorkbenchProps { items: WarehouseInventoryItem[]; @@ -138,6 +138,42 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo } }; + // One-click bundle: download every available document for the item (GRN + + // gate clearance / release order + handover). Best-effort — docs that aren't + // generatable yet for this item are skipped. + const downloadDocumentBundle = async (item: WarehouseInventoryItem) => { + setBusyId(item.id); + const ref = item.booking?.reference ?? item.bookingId ?? item.id; + const jobs: Array<{ name: string; fn: () => Promise<{ data: Blob }> }> = [ + { name: `GRN-${ref}.pdf`, fn: () => warehouseService.downloadGrnDocument(item.id) }, + { name: `gate-clearance-${ref}.pdf`, fn: () => warehouseService.downloadReleaseDocument(item.id) }, + { name: `handover-${ref}.pdf`, fn: () => warehouseService.downloadHandoverDocument(item.id) }, + ]; + let saved = 0; + for (const job of jobs) { + try { + const response = await job.fn(); + saveBlob(response.data, job.name); + saved += 1; + } catch { + // Document not available for this item yet — skip it. + } + } + setBusyId(null); + if (saved === 0) { + toast({ + variant: 'destructive', + title: 'No documents available', + description: 'This item has no GRN, gate clearance or handover document yet.', + }); + } else { + toast({ + title: `Downloaded ${saved} document${saved !== 1 ? 's' : ''}`, + description: `Bundle for ${ref} (available documents only).`, + }); + } + }; + const acceptLastMile = async (item: WarehouseInventoryItem) => { const reference = item.booking?.reference; if (!reference) { @@ -243,6 +279,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo onFeePreview={setFeeItem} onReleaseDocument={downloadReleaseDocument} onHandoverDocument={openHandoverDocument} + onDownloadBundle={downloadDocumentBundle} onLastMile={onLastMile ? acceptLastMile : undefined} selectedIds={selected} onToggleSelect={toggleSelect} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx index 51650b459..de262767f 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx @@ -1,6 +1,6 @@ import { useState, type MouseEvent } from 'react'; import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core'; -import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react'; +import { ArrowRightLeft, ClipboardList, Coins, Download, Eye, FileText, History, MapPin } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; import { warehouseService } from '@/services/warehouse.service'; @@ -24,6 +24,7 @@ interface WarehouseInventoryTableProps { onFeePreview?: (item: WarehouseInventoryItem) => void; onReleaseDocument?: (item: WarehouseInventoryItem) => void; onHandoverDocument?: (item: WarehouseInventoryItem) => void; + onDownloadBundle?: (item: WarehouseInventoryItem) => void; onLastMile?: (item: WarehouseInventoryItem) => void; selectedIds?: Set; onToggleSelect?: (id: string) => void; @@ -110,6 +111,7 @@ export function WarehouseInventoryTable({ onFeePreview, onReleaseDocument, onHandoverDocument, + onDownloadBundle, onLastMile, selectedIds, onToggleSelect, @@ -285,6 +287,13 @@ export function WarehouseInventoryTable({ )} + {onDownloadBundle && item.grnNumber && ( + + onDownloadBundle(item)}> + + + + )} {onLastMile && item.booking?.lastMileDeliveryAddress && ( onLastMile(item)}> diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts index da20d6fa7..7b3504480 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts @@ -31,3 +31,4 @@ export { InspectionReportModal } from './InspectionReportModal'; export { FeePreviewModal } from './FeePreviewModal'; export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap'; export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip'; +export { AccrualDashboard } from './AccrualDashboard'; diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/pdf.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/pdf.ts index 7a467b9db..91ca2ec16 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/pdf.ts +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/pdf.ts @@ -22,3 +22,16 @@ export function openPdfBlob(blob: Blob, filename: string, targetWindow?: Window URL.revokeObjectURL(url); return false; } + +/** Force a browser download of a blob under the given filename (no preview tab). */ +export function saveBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + // Delay revoke so the download has time to start (esp. for rapid multi-saves). + setTimeout(() => URL.revokeObjectURL(url), 10_000); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index f76dbbde2..bae6361fb 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -558,6 +558,7 @@ export const URL_CONSTANTS = { FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`, FEE_PREVIEW: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-preview`, + ACCRUAL_DASHBOARD: "/warehouse-fees/accrual-dashboard", }, WAREHOUSE_INVOICES: { diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts index da1bfb867..56ef4876b 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -152,6 +152,14 @@ export function useWarehouseOpsStats() { }); } +/** Live per-item fee accrual (storage/demurrage) with alerts. */ +export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') { + return useQuery({ + queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'], + queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data), + }); +} + export function useCreateZone() { const qc = useQueryClient(); return useMutation({ diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx index 6f4c16594..1f632c1e7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx @@ -20,6 +20,7 @@ import { useNavigate } from 'react-router-dom'; import { DataTable, type ColumnDef } from '@edr/ui-common'; import { PageContainer, PageHeader } from '@/components/page'; +import { AccrualDashboard } from '@/components/warehouses'; import { useMutation, useQuery } from '@tanstack/react-query'; import { api } from '@/services/api'; @@ -122,6 +123,13 @@ export default function WarehouseInvoicesPage() { subtitle="Demurrage & storage invoices generated from warehouse fee rules." /> + + + Accruing now + + + + (URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId), { params: cleanParams({ billingCurrency }), }), + accrualDashboard: (billingCurrency?: 'ETB' | 'USD') => + apiClient.get(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, { + params: cleanParams({ billingCurrency }), + }), // ── Batch 6: Warehouse fee invoices ──────────────────────────────────────── listInvoices: (filter?: WarehouseInvoiceFilter) => diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index eb04c39bc..7ef649bea 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -1115,3 +1115,30 @@ export interface WarehouseOpsStats { trucksOnSite: number; itemsAging: number; } + +export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING'; + +/** One item's live fee accrual for the accrual dashboard. */ +export interface AccrualDashboardRow { + inventoryId: string; + status: string; + bookingId: string | null; + companyId: string | null; + bookingReference: string | null; + customerName: string | null; + warehouseCode: string | null; + zoneCode: string | null; + receivedAt: string | null; + currency: string; + accruedAmount: number; + freeDaysLeft: number | null; + charging: boolean; + alert: AccrualAlert; + breakdown: Array<{ + type: string; + amount: number; + freeDays: number; + elapsedDays: number; + chargeableDays: number; + }>; +} diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 1128b0883..850081ea1 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -209,6 +209,20 @@ export const bookingsService = { ); return data; }, + downloadBookingGrnDocument: async (bookingId: string): Promise => { + const { data } = await client.get( + `/api/warehouse-inventory/bookings/${bookingId}/grn-document`, + { responseType: "blob" }, + ); + return data; + }, + downloadBookingReleaseDocument: async (bookingId: string): Promise => { + const { data } = await client.get( + `/api/warehouse-inventory/bookings/${bookingId}/release-document`, + { responseType: "blob" }, + ); + return data; + }, tracking: async (id: string): Promise => { const { data } = await client.get(`/api/bookings/${id}/tracking`); return data.data; From a3673065654076b979fe2457e66a88ec27ca21dc Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 14 Jul 2026 09:35:21 +0000 Subject: [PATCH 3/4] feat(warehouse): accrual acknowledge/snooze + zone weight fix, handover signer, portal delivery Accrual dashboard: - warehouse_accrual_acks table (migration 2140) + acknowledge/unacknowledge endpoints; dashboard rows carry acknowledged/snoozeUntil, acked items sink and are skipped by the alert cron. Row menu: mark reviewed / snooze 3d / 7d / un-acknowledge; acked rows dimmed with a "Reviewed" badge. - Fix zone weight occupancy: normalise inventory kg vs zone-capacity tonnes. Handover (rode along, shared files): - Require signer full name on delivery handover (signature optional); migration 2130 adds signer_name. Portal delivery/docs (rode along, shared files): - Approve-delivery name capture, booking-scoped GRN/release docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2130000000000-AddHandoverSignerName.ts | 23 +++++ .../2140000000000-CreateAccrualAcks.ts | 29 +++++++ .../warehouses/dto/acknowledge-accrual.dto.ts | 18 ++++ .../warehouses/dto/approve-delivery.dto.ts | 11 +++ .../entities/booking-handover.entity.ts | 4 + .../modules/warehouses/handover.service.ts | 12 ++- .../warehouses/warehouse-fee.service.ts | 56 +++++++++++- .../warehouse-inventory.controller.ts | 10 ++- .../warehouses/warehouse-inventory.service.ts | 33 ++++--- .../warehouses/warehouse-rules.controller.ts | 20 +++++ .../warehouses/AccrualDashboard.tsx | 86 +++++++++++++++++-- .../backoffice/src/constants/URLS.ts | 2 + .../src/services/warehouse.service.ts | 4 + .../backoffice/src/types/warehouse.ts | 2 + .../components/DocumentsTab.tsx | 54 ++++++++++++ .../delivery/ApproveDeliveryModal.tsx | 21 +++-- .../portal/src/services/api.ts | 4 +- .../portal/src/services/bookings.service.ts | 6 +- 18 files changed, 362 insertions(+), 33 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2130000000000-AddHandoverSignerName.ts create mode 100644 apps/edr-freight-api/src/migrations/2140000000000-CreateAccrualAcks.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/dto/acknowledge-accrual.dto.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/dto/approve-delivery.dto.ts diff --git a/apps/edr-freight-api/src/migrations/2130000000000-AddHandoverSignerName.ts b/apps/edr-freight-api/src/migrations/2130000000000-AddHandoverSignerName.ts new file mode 100644 index 000000000..4e4c5f5fc --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2130000000000-AddHandoverSignerName.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * The person who signs off a handover must record their full name (a signature + * is optional, especially for self-haul). Stored per handover record. + */ +export class AddHandoverSignerName2130000000000 implements MigrationInterface { + name = "AddHandoverSignerName2130000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_handovers + ADD COLUMN IF NOT EXISTS signer_name varchar(160) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_handovers + DROP COLUMN IF EXISTS signer_name + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2140000000000-CreateAccrualAcks.ts b/apps/edr-freight-api/src/migrations/2140000000000-CreateAccrualAcks.ts new file mode 100644 index 000000000..5e13a6e3d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2140000000000-CreateAccrualAcks.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Accrual alert acknowledgements: ops can mark an in-warehouse item's fee + * accrual as reviewed (optionally snoozed until a date) so it stops nudging and + * drops down the accrual dashboard. One row per inventory item. + */ +export class CreateAccrualAcks2140000000000 implements MigrationInterface { + name = "CreateAccrualAcks2140000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_accrual_acks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + inventory_id uuid NOT NULL UNIQUE, + acknowledged_by uuid, + acknowledged_at timestamptz NOT NULL DEFAULT now(), + snooze_until timestamptz, + note text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_accrual_acks`); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/acknowledge-accrual.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/acknowledge-accrual.dto.ts new file mode 100644 index 000000000..6b3697f03 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/acknowledge-accrual.dto.ts @@ -0,0 +1,18 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; + +/** Acknowledge (optionally snooze) an item's fee-accrual alert. */ +export class AcknowledgeAccrualDto { + @ApiPropertyOptional({ minimum: 1, maximum: 90, description: 'Days to suppress alerts; omit = indefinitely.' }) + @IsOptional() + @IsInt() + @Min(1) + @Max(90) + snoozeDays?: number; + + @ApiPropertyOptional({ description: 'Optional reason / note.' }) + @IsOptional() + @IsString() + @MaxLength(500) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/approve-delivery.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/approve-delivery.dto.ts new file mode 100644 index 000000000..2ef499201 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/approve-delivery.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +/** The customer approving a handover must record their full name (signature optional). */ +export class ApproveDeliveryDto { + @ApiProperty({ description: 'Full name of the person approving delivery.' }) + @IsString() + @IsNotEmpty() + @MaxLength(160) + signerName!: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts index f5a730ea8..a0a7ad70f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts @@ -37,6 +37,10 @@ export class BookingHandover extends BaseEntity { @Column({ name: 'signed_at', type: 'timestamptz', nullable: true }) signedAt?: Date | null; + /** Full name of the person who signed off the handover (required at sign time). */ + @Column({ name: 'signer_name', type: 'varchar', length: 160, nullable: true }) + signerName?: string | null; + @Column({ name: 'signed_by_user_id', type: 'uuid', nullable: true }) signedByUserId?: string | null; diff --git a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts index 48ab3ac48..d50b27150 100644 --- a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts @@ -183,12 +183,20 @@ export class HandoverService { } /** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */ - async signForBooking(bookingId: string, userId?: string | null): Promise { + async signForBooking( + bookingId: string, + userId?: string | null, + signerName?: string | null, + ): Promise { await this.dataSource .getRepository(BookingHandover) .update( { bookingId, signedAt: IsNull() }, - { signedAt: new Date(), signedByUserId: userId ?? null }, + { + signedAt: new Date(), + signedByUserId: userId ?? null, + signerName: signerName?.trim() || null, + }, ); } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index 720f30766..92aa36b85 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -46,6 +46,9 @@ export interface AccrualDashboardRow { freeDaysLeft: number | null; charging: boolean; alert: AccrualAlert; + /** Reviewed by ops — suppressed from alerts (snoozed until snoozeUntil, or indefinitely). */ + acknowledged: boolean; + snoozeUntil: string | null; breakdown: Array<{ type: FeeRuleType; amount: number; @@ -116,7 +119,9 @@ export class WarehouseFeeService { @Cron(CronExpression.EVERY_DAY_AT_6AM, { name: 'warehouse-accrual-alert' }) async sendAccrualAlerts(): Promise { try { - const alerts = (await this.accrualDashboard()).filter((r) => r.alert !== 'OK'); + const alerts = (await this.accrualDashboard()).filter( + (r) => r.alert !== 'OK' && !r.acknowledged, + ); if (!alerts.length) return; this.logger.log(`Accrual alerts: ${alerts.length} item(s) charging or nearing charges`); @@ -549,6 +554,14 @@ export class WarehouseFeeService { ORDER BY inv.created_at ASC`, ); + const ackRows: Array<{ inventoryId: string; snoozeUntil: string | null }> = + await this.dataSource.query( + `SELECT inventory_id AS "inventoryId", snooze_until AS "snoozeUntil" + FROM freight.warehouse_accrual_acks`, + ); + const now = new Date(); + const acks = new Map(ackRows.map((a) => [a.inventoryId, a.snoozeUntil])); + const rows = await Promise.all( items.map(async (it): Promise => { const previews = (await this.previewForInventory(it.id, billingCurrency)).filter( @@ -581,6 +594,10 @@ export class WarehouseFeeService { freeDaysLeft, charging, alert, + acknowledged: + acks.has(it.id) && + (acks.get(it.id) == null || new Date(acks.get(it.id) as string) > now), + snoozeUntil: acks.get(it.id) ?? null, breakdown: previews.map((p) => ({ type: p.ruleType, amount: p.amount, @@ -593,8 +610,43 @@ export class WarehouseFeeService { ); const rank = (a: AccrualAlert) => (a === 'CHARGING' ? 0 : a === 'WARNING' ? 1 : 2); + // Acknowledged items sink to the bottom; among the rest, worst alert first. return rows.sort( - (a, b) => rank(a.alert) - rank(b.alert) || b.accruedAmount - a.accruedAmount, + (a, b) => + Number(a.acknowledged) - Number(b.acknowledged) || + rank(a.alert) - rank(b.alert) || + b.accruedAmount - a.accruedAmount, + ); + } + + /** Mark an item's accrual reviewed. `snoozeDays` > 0 suppresses alerts until then; omitted = indefinitely. */ + async acknowledgeAccrual( + inventoryId: string, + opts: { snoozeDays?: number; note?: string; userId?: string } = {}, + ): Promise { + const snoozeUntil = + opts.snoozeDays && opts.snoozeDays > 0 + ? new Date(Date.now() + opts.snoozeDays * 24 * 60 * 60 * 1000) + : null; + await this.dataSource.query( + `INSERT INTO freight.warehouse_accrual_acks + (inventory_id, acknowledged_by, acknowledged_at, snooze_until, note, updated_at) + VALUES ($1, $2, now(), $3, $4, now()) + ON CONFLICT (inventory_id) DO UPDATE + SET acknowledged_by = EXCLUDED.acknowledged_by, + acknowledged_at = now(), + snooze_until = EXCLUDED.snooze_until, + note = EXCLUDED.note, + updated_at = now()`, + [inventoryId, opts.userId ?? null, snoozeUntil, opts.note?.trim() || null], + ); + } + + /** Remove an acknowledgement so the item re-surfaces for alerts. */ + async unacknowledgeAccrual(inventoryId: string): Promise { + await this.dataSource.query( + `DELETE FROM freight.warehouse_accrual_acks WHERE inventory_id = $1`, + [inventoryId], ); } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index f45abe507..055c9bac9 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -11,6 +11,7 @@ import { LoadInventoryDto } from './dto/load-inventory.dto'; import { MoveInventoryDto } from './dto/move-inventory.dto'; import { StoreInventoryDto } from './dto/store-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; +import { ApproveDeliveryDto } from './dto/approve-delivery.dto'; import { ReleaseOrderDto } from './dto/release-order.dto'; import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto'; @@ -348,12 +349,17 @@ export class WarehouseInventoryController { } @Post('bookings/:bookingId/approve-delivery') - @ApiOperation({ summary: "Approve delivery using the current customer's saved signature" }) + @ApiOperation({ summary: "Approve delivery — customer records their full name (signature optional)" }) approveDeliveryForBooking( @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: ApproveDeliveryDto, @Request() req: { user?: { id?: string; sub?: string } }, ) { - return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub); + return this.inventoryService.approveDeliveryForBooking( + bookingId, + req.user?.id ?? req.user?.sub, + dto.signerName, + ); } @Get('bookings/:bookingId/handovers') diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 8bce58f01..1c1be66ec 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -493,14 +493,17 @@ export class WarehouseInventoryService { return rows.map((r) => { const capWeight = r.capacityWeight != null ? Number(r.capacityWeight) : null; + // Zone capacity_weight is in TONNES; inventory weight is in KG — normalise + // used weight to tonnes before comparing so weight occupancy is correct. + const usedWeightTons = r.usedWeight / 1000; const byWeight = - capWeight && capWeight > 0 ? (r.usedWeight / capWeight) * 100 : null; + capWeight && capWeight > 0 ? (usedWeightTons / capWeight) * 100 : null; const byItems = r.capacityContainers && r.capacityContainers > 0 ? (r.usedItems / r.capacityContainers) * 100 : null; - // Prefer container-count occupancy (unit-consistent). Weight capacity is - // tonnes while inventory weight is kg, so weight% is only a rough fallback. + // Container zones use item-count occupancy; bulk zones (no container cap) + // fall back to the now unit-correct weight occupancy. const pct = byItems ?? byWeight; return { id: r.id, @@ -3171,16 +3174,20 @@ export class WarehouseInventoryService { async approveDeliveryForBooking( bookingId: string, userId?: string, + signerName?: string, ): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> { if (!userId) { throw new BadRequestException('Authentication is required to approve delivery'); } - - const signature = await this.signatures.getForUser(userId); - if (!signature?.signatureImageUrl) { - throw new BadRequestException('Please save your signature before approving delivery'); + const name = signerName?.trim(); + if (!name) { + throw new BadRequestException('Please enter your full name to approve delivery'); } + // A saved signature is applied when available; otherwise the typed full name + // is the record of who approved (self-haul customers may have no signature). + const signature = await this.signatures.getForUser(userId).catch(() => null); + const [item]: Array<{ id: string; warehouseId: string | null; @@ -3215,8 +3222,8 @@ export class WarehouseInventoryService { const approvedAt = new Date(); const approval = { approvedAt: approvedAt.toISOString(), - signerDisplayName: signature.signerDisplayName, - signatureImageUrl: signature.signatureImageUrl, + signerDisplayName: name, + signatureImageUrl: signature?.signatureImageUrl ?? null, userId, }; const existingNotes = this.stripCustomerDeliveryApproval(item.notes); @@ -3231,8 +3238,8 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_RELEASED', inventoryId: item.id, warehouseId: item.warehouseId, - description: `Customer approved delivery as ${signature.signerDisplayName}`, - performedBy: signature.signerDisplayName, + description: `Customer approved delivery as ${name}`, + performedBy: name, }, manager, ); @@ -3240,13 +3247,13 @@ export class WarehouseInventoryService { // Sign the structured handover record(s) for this booking (self-haul: before // the truck leaves). Kept alongside the legacy approval note. - await this.handover.signForBooking(bookingId, userId); + await this.handover.signForBooking(bookingId, userId, name); return { bookingId, inventoryId: item.id, approvedAt: approval.approvedAt, - signerDisplayName: signature.signerDisplayName, + signerDisplayName: name, }; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts index 1fecf9392..19788e027 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts @@ -7,6 +7,7 @@ import { UpdateAllocationRuleDto, } from './dto/allocation-rule.dto'; import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; +import { AcknowledgeAccrualDto } from './dto/acknowledge-accrual.dto'; import { WarehouseAllocationService } from './warehouse-allocation.service'; import { WarehouseFeeService } from './warehouse-fee.service'; @@ -83,6 +84,25 @@ export class WarehouseRulesController { return this.feeService.accrualDashboard(billingCurrency); } + @Post('warehouse-fees/accrual/:inventoryId/acknowledge') + @ApiOperation({ summary: 'Acknowledge / snooze an item fee-accrual alert' }) + acknowledgeAccrual( + @Param('inventoryId', ParseUUIDPipe) inventoryId: string, + @Body() dto: AcknowledgeAccrualDto, + ) { + return this.feeService.acknowledgeAccrual(inventoryId, { + snoozeDays: dto.snoozeDays, + note: dto.note, + }); + } + + @Delete('warehouse-fees/accrual/:inventoryId/acknowledge') + @HttpCode(204) + @ApiOperation({ summary: 'Remove an accrual acknowledgement (re-surface for alerts)' }) + unacknowledgeAccrual(@Param('inventoryId', ParseUUIDPipe) inventoryId: string) { + return this.feeService.unacknowledgeAccrual(inventoryId); + } + @Get('warehouse-inventory/:id/fee-preview') @ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' }) feePreview( diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx index 2ac3fa2f9..5170b345d 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx @@ -1,8 +1,11 @@ import { useMemo } from 'react'; -import { Badge, Card, Group, Loader, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core'; -import { AlertTriangle, Clock, DollarSign } from 'lucide-react'; +import { ActionIcon, Badge, Card, Group, Loader, Menu, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { AlertTriangle, Bell, BellOff, Check, Clock, DollarSign, MoreVertical } from 'lucide-react'; import { useAccrualDashboard } from '@/hooks/useWarehouses'; +import { warehouseService } from '@/services/warehouse.service'; +import { useToast } from '@/hooks/use-toast'; import type { AccrualAlert, AccrualDashboardRow } from '@/types/warehouse'; const ALERT_META: Record = { @@ -27,6 +30,29 @@ function freeDaysLabel(row: AccrualDashboardRow): string { */ export function AccrualDashboard() { const { data: rows = [], isLoading } = useAccrualDashboard(); + const { toast } = useToast(); + const qc = useQueryClient(); + + const refresh = () => + qc.invalidateQueries({ queryKey: ['warehouse-fees', 'accrual-dashboard'] }); + + const ack = useMutation({ + mutationFn: ({ id, snoozeDays }: { id: string; snoozeDays?: number }) => + warehouseService.acknowledgeAccrual(id, snoozeDays ? { snoozeDays } : {}), + onSuccess: (_r, v) => { + toast({ title: v.snoozeDays ? `Snoozed ${v.snoozeDays} days` : 'Marked reviewed' }); + void refresh(); + }, + onError: () => toast({ variant: 'destructive', title: 'Could not acknowledge' }), + }); + const unack = useMutation({ + mutationFn: (id: string) => warehouseService.unacknowledgeAccrual(id), + onSuccess: () => { + toast({ title: 'Acknowledgement removed' }); + void refresh(); + }, + onError: () => toast({ variant: 'destructive', title: 'Could not un-acknowledge' }), + }); const summary = useMemo(() => { const currency = rows[0]?.currency ?? 'USD'; @@ -86,13 +112,15 @@ export function AccrualDashboard() { Accrued Free days Alert + {rows.map((row) => { const meta = ALERT_META[row.alert]; + const busy = ack.isPending || unack.isPending; return ( - + {row.bookingReference ?? row.inventoryId.slice(0, 8)} @@ -120,9 +148,55 @@ export function AccrualDashboard() { - - {meta.label} - + {row.acknowledged ? ( + }> + Reviewed{row.snoozeUntil ? ' (snoozed)' : ''} + + ) : ( + + {meta.label} + + )} + + + + + + + + + + {row.acknowledged ? ( + } + onClick={() => unack.mutate(row.inventoryId)} + > + Un-acknowledge + + ) : ( + <> + } + onClick={() => ack.mutate({ id: row.inventoryId })} + > + Mark reviewed + + } + onClick={() => ack.mutate({ id: row.inventoryId, snoozeDays: 3 })} + > + Snooze 3 days + + } + onClick={() => ack.mutate({ id: row.inventoryId, snoozeDays: 7 })} + > + Snooze 7 days + + + )} + + ); diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index bae6361fb..618d0ba30 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -559,6 +559,8 @@ export const URL_CONSTANTS = { FEE_PREVIEW: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-preview`, ACCRUAL_DASHBOARD: "/warehouse-fees/accrual-dashboard", + ACCRUAL_ACK: (inventoryId: string) => + `/warehouse-fees/accrual/${inventoryId}/acknowledge`, }, WAREHOUSE_INVOICES: { diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index 0ab883ff8..febde7f85 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -430,6 +430,10 @@ export const warehouseService = { apiClient.get(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, { params: cleanParams({ billingCurrency }), }), + acknowledgeAccrual: (inventoryId: string, body: { snoozeDays?: number; note?: string } = {}) => + apiClient.post(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_ACK(inventoryId), body), + unacknowledgeAccrual: (inventoryId: string) => + apiClient.delete(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_ACK(inventoryId)), // ── Batch 6: Warehouse fee invoices ──────────────────────────────────────── listInvoices: (filter?: WarehouseInvoiceFilter) => diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index 7ef649bea..7a88b136e 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -1134,6 +1134,8 @@ export interface AccrualDashboardRow { freeDaysLeft: number | null; charging: boolean; alert: AccrualAlert; + acknowledged: boolean; + snoozeUntil: string | null; breakdown: Array<{ type: string; amount: number; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx index a24f34763..fe7f58e62 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx @@ -21,6 +21,10 @@ import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModa import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction"; import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; +import toast from "react-hot-toast"; + +import { bookingsService } from "@/services/bookings.service"; +import { saveBlob } from "@/utils/download"; import { fmtDate } from "../utils"; import { IconSquare } from "./Documents"; import { CardTitle, SectionCard } from "./layout"; @@ -361,6 +365,39 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) { const company = contract?.company; + // One-click warehouse-document bundle: GRN + gate clearance + handover. + const [bundleBusy, setBundleBusy] = useState(false); + const downloadWarehouseDocuments = async () => { + setBundleBusy(true); + const ref = booking.reference ?? booking.id; + const jobs: Array<{ name: string; fn: () => Promise }> = [ + { name: `GRN-${ref}.pdf`, fn: () => bookingsService.downloadBookingGrnDocument(booking.id) }, + { + name: `gate-clearance-${ref}.pdf`, + fn: () => bookingsService.downloadBookingReleaseDocument(booking.id), + }, + { + name: `handover-${ref}.pdf`, + fn: () => bookingsService.downloadBookingHandoverDocument(booking.id), + }, + ]; + let saved = 0; + for (const job of jobs) { + try { + saveBlob(await job.fn(), job.name); + saved += 1; + } catch { + // Document not available for this booking yet — skip it. + } + } + setBundleBusy(false); + if (saved === 0) { + toast.error("No warehouse documents are available for this booking yet."); + } else { + toast.success(`Downloaded ${saved} document${saved !== 1 ? "s" : ""}.`); + } + }; + return ( {/* ── 1. Clearance documents ──────────────────────────────────────── */} @@ -552,6 +589,23 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) { )} + {/* ── Warehouse documents (one-click bundle) ──────────────────────── */} + + Warehouse documents + + Goods Received Note, gate clearance / release order and handover — download all + available documents for this booking in one click. + + + + {!hasContract && otherBookingFiles.length === 0 && ( }> diff --git a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx index a3723b650..90e95369f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx @@ -1,4 +1,4 @@ -import { Alert, Button, Group, Loader, Modal, Stack, Text } from "@mantine/core"; +import { Alert, Button, Group, Loader, Modal, Stack, Text, TextInput } from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CheckCircle2, Info } from "lucide-react"; import { useEffect, useState } from "react"; @@ -47,6 +47,7 @@ export function ApproveDeliveryModal({ const navigate = useNavigate(); const queryClient = useQueryClient(); const [pdfUrl, setPdfUrl] = useState(null); + const [signerName, setSignerName] = useState(""); const { data: docBlob, @@ -122,8 +123,9 @@ export function ApproveDeliveryModal({ }> - Review the handover document below. Approving applies your saved signature - and confirms you received the goods. + Review the handover document below, then type your full name to sign and + confirm you received the goods. Your saved signature is applied automatically + if you have one. @@ -151,6 +153,15 @@ export function ApproveDeliveryModal({ /> )} + setSignerName(e.currentTarget.value)} + disabled={busy} + /> + diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 6cd606b2e..6f4527c0b 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -387,10 +387,10 @@ export const api = { ({ orderId }) => bookingsService.checkPayment(orderId), ), - approveDelivery: endpoint<{ id: string }, ApproveDeliveryResponse>( + approveDelivery: endpoint<{ id: string; signerName: string }, ApproveDeliveryResponse>( "bookings", "approveDelivery", - ({ id }) => bookingsService.approveDelivery(id), + ({ id, signerName }) => bookingsService.approveDelivery(id, signerName), ), getBookableSchedules: endpoint< diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 850081ea1..a60fe4275 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -373,9 +373,13 @@ export const bookingsService = { return data.data ?? data; }, - approveDelivery: async (id: string): Promise => { + approveDelivery: async ( + id: string, + signerName: string, + ): Promise => { const { data } = await client.post( `/api/warehouse-inventory/bookings/${id}/approve-delivery`, + { signerName }, ); return data.data ?? data; }, From 65f6015c5e148ada9e2d8696abb7609fec37f216 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 14 Jul 2026 12:52:49 +0000 Subject: [PATCH 4/4] feat(warehouse): guard warehouse/inventory/fee endpoints with RBAC permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply JwtGuard + FreightPermissionGuard (via @BookingStaff) to 83 staff endpoints across the 8 warehouse controllers, using existing edr_freight_app:warehouse* permissions: warehouses/yards/zones, inventory receive/move/load/unload/dispatch/gate-pass/release/deliver/inspect (incl. import & export queues), allocation + fee rules (demurrage/storage/ double-handling), accrual dashboard + acknowledge, and fee invoices. Customer-portal endpoints (booking-scoped documents, approve-delivery, portal fee-invoice view/document/receipt/pay-online) are intentionally left unguarded — they need a customer-ownership guard, not staff permissions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouse-inspection.controller.ts | 6 +++ .../warehouse-inventory.controller.ts | 48 +++++++++++++++++++ .../warehouse-invoice.controller.ts | 8 ++++ .../warehouse-loadings.controller.ts | 3 ++ .../warehouses/warehouse-rules.controller.ts | 16 +++++++ .../warehouses/warehouse-yards.controller.ts | 6 +++ .../warehouses/warehouse-zones.controller.ts | 4 ++ .../warehouses/warehouses.controller.ts | 8 ++++ 8 files changed, 99 insertions(+) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts index 533b0c6ae..7bd56e593 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts @@ -12,6 +12,8 @@ import { import { AnyFilesInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateInspectionReportDto } from './dto/create-inspection-report.dto'; import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; import { WarehouseInspectionService } from './warehouse-inspection.service'; @@ -19,10 +21,12 @@ import { WarehouseInspectionService } from './warehouse-inspection.service'; @ApiTags('warehouse-inspection') @ApiBearerAuth() @Controller() +@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.view) export class WarehouseInspectionController { constructor(private readonly inspectionService: WarehouseInspectionService) {} @Post('warehouse-inventory/:inventoryId/inspection-reports') + @BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.create) @ApiOperation({ summary: 'Create an inspection / damage report for an inventory item' }) create( @Param('inventoryId', ParseUUIDPipe) inventoryId: string, @@ -46,12 +50,14 @@ export class WarehouseInspectionController { } @Patch('warehouse-inspection-reports/:id') + @BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.update) @ApiOperation({ summary: 'Update an inspection report' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateInspectionReportDto) { return this.inspectionService.update(id, dto); } @Post('warehouse-inspection-reports/:id/attachments') + @BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.update) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Upload inspection images / documents' }) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 055c9bac9..8b14e6cdc 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -2,6 +2,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Reques import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import type { Response } from 'express'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { BulkReceiveDto } from './dto/bulk-receive.dto'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; @@ -30,54 +32,63 @@ export class WarehouseInventoryController { ) {} @Get() + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'List warehouse inventory' }) findAll(@Query() filter: FilterWarehouseInventoryDto) { return this.inventoryService.findAll(filter); } @Get('ready-for-loading') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'List inventory ready for loading' }) findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) { return this.inventoryService.findReadyForLoading(filter); } @Get('inquiry') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Locate any item inside the warehouse' }) inquiry(@Query() filter: InquiryWarehouseInventoryDto) { return this.inventoryService.inquiry(filter); } @Get('arrival-queue') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Arrived bookings awaiting unload / inspection' }) arrivalQueue() { return this.inventoryService.arrivalQueue(); } @Get('ops-stats') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'At-a-glance warehouse ops counters for the KPI strip' }) opsStats() { return this.inventoryService.opsStats(); } @Get('zone-occupancy') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' }) zoneOccupancy(@Query('yardId') yardId?: string) { return this.inventoryService.zoneOccupancy(yardId); } @Post('auto-unload-arrived') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.unload) @ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' }) autoUnloadArrived() { return this.inventoryService.autoUnloadArrived(); } @Post('auto-load-ready') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.load) @ApiOperation({ summary: 'Auto-load READY_FOR_LOADING inventory with PAID bookings' }) autoLoadReady() { return this.inventoryService.autoLoadReady(); } @Get('eligible-bookings') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'PAID bookings not yet received, classified IMPORT/EXPORT by route; omit direction for all' }) eligibleBookings(@Query('direction') direction?: string) { const dir = direction === 'IMPORT' || direction === 'EXPORT' ? direction : undefined; @@ -85,6 +96,7 @@ 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) { return this.inventoryService.bulkReceive(dto); @@ -92,36 +104,42 @@ export class WarehouseInventoryController { @Get('ready-to-load-export') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' }) readyToLoadExport() { return this.inventoryService.readyToLoadExport(); } @Get('received-export') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'EXPORT inventory that has been received and is awaiting inspection' }) receivedExport() { return this.inventoryService.receivedExport(); } @Get('loaded-export') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' }) loadedExport() { return this.inventoryService.loadedExport(); } @Get('loadable-trains') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' }) loadableTrains() { return this.inventoryService.loadableTrains(); } @Get('train/:scheduleId/loadable-items') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Container/cargo inventory assigned to a train, with allocated wagons' }) trainLoadableItems(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) { return this.inventoryService.trainLoadableItems(scheduleId); } @Post('train/:scheduleId/load') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.load) @ApiOperation({ summary: 'Load selected inventory items onto their allocated wagons for a train' }) loadItemsOntoTrain( @Param('scheduleId', ParseUUIDPipe) scheduleId: string, @@ -131,18 +149,21 @@ export class WarehouseInventoryController { } @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); } @Post('bulk-mark-inspected') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.inspect) @ApiOperation({ summary: 'Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)' }) bulkMarkInspected(@Body() dto: BulkInspectDto) { return this.inventoryService.bulkMarkInspected(dto); } @Post('bookings/:bookingId/unload') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.unload) @ApiOperation({ summary: 'Unload a single arrived booking into a location' }) unloadBooking( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -152,24 +173,28 @@ 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); } @Get('import/arrive-queue') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Arrived IMPORT train schedules (route-derived), read-only from scheduling' }) importArriveQueue() { return this.scheduling.importArriveQueue(); } @Get('import/trains/:scheduleId/items') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Assigned bookings/items for an arrived import train (read-only)' }) importTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) { return this.scheduling.importTrainDetail(scheduleId); } @Post('import/auto-unload-arrived-bookings') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.unload) @ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' }) autoUnloadArrivedBookings(@Body() dto: { scheduleId: string; @@ -186,12 +211,14 @@ export class WarehouseInventoryController { } @Get('import/unloaded-queue') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection)' }) importUnloadedQueue() { return this.inventoryService.importUnloadedQueue(); } @Get('export/djibouti-arrival-queue') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Arrived EXPORT train schedules at Djibouti-side ports, ready for unloading' }) exportDjiboutiArrivalQueue( @Query('scheduleId') scheduleId?: string, @@ -210,102 +237,119 @@ export class WarehouseInventoryController { } @Get('export/djibouti-trains/:scheduleId/items') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Assigned export bookings/items for an arrived Djibouti-side train' }) exportDjiboutiTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) { return this.scheduling.exportDjiboutiTrainDetail(scheduleId); } @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); } @Get('import/pickup-ready-queue') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' }) importPickupReadyQueue() { return this.inventoryService.importPickupReadyQueue(); } @Get('loadable-wagons') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' }) loadableWagons() { return this.scheduling.listLoadableWagons(); } @Get('booking/:bookingId/schedule') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Read-only schedule + wagon + departure status for a booking' }) bookingSchedule(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.scheduling.getBookingSchedule(bookingId); } @Post('receive') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.receive) @ApiOperation({ summary: 'Receive inventory at a warehouse location' }) receive(@Body() dto: ReceiveWarehouseInventoryDto) { 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) { return this.inventoryService.reserve(dto); } @Get(':id/movements') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Inventory movement history' }) movements(@Param('id', ParseUUIDPipe) id: string) { return this.inventoryService.findMovements(id); } @Get(':id/activity') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Inventory activity log' }) activity(@Param('id', ParseUUIDPipe) id: string) { return this.inventoryService.findActivity(id); } @Get(':id/loadings') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Loading records for an inventory item' }) loadings(@Param('id', ParseUUIDPipe) id: string) { return this.inventoryService.findLoadingsByInventory(id); } @Post(':id/move') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) @ApiOperation({ summary: 'Move inventory to another warehouse/yard/zone' }) move(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveInventoryDto) { return this.inventoryService.move(id, dto); } @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); } @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); } @Post(':id/load') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.load) @ApiOperation({ summary: 'Load READY_FOR_LOADING inventory onto a wagon' }) load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadInventoryDto) { return this.inventoryService.load(id, dto); } @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); } @Post(':id/release') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.release) @ApiOperation({ summary: 'Issue a DO / release order for ready-for-pickup inventory' }) release(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReleaseOrderDto) { return this.inventoryService.release(id, dto); } @Get(':id/release-document') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'View warehouse release / exit paper PDF' }) async releaseDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { const { filename, buffer } = await this.inventoryService.releaseDocument(id); @@ -316,6 +360,7 @@ export class WarehouseInventoryController { } @Get('customer-truck-exit-paper/:assignmentId') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Per-truck exit paper PDF (containers loaded on one customer truck)' }) async truckExitPaper( @Param('assignmentId', ParseUUIDPipe) assignmentId: string, @@ -329,6 +374,7 @@ export class WarehouseInventoryController { } @Get(':id/grn-document') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'View goods received note PDF' }) async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { const { filename, buffer } = await this.inventoryService.grnDocument(id); @@ -417,12 +463,14 @@ 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) { 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); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts index a1c5db837..635ca1a10 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts @@ -2,6 +2,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import type { Response } from 'express'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto'; import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto'; import { WarehouseInvoiceService } from './warehouse-invoice.service'; @@ -13,12 +15,14 @@ export class WarehouseInvoiceController { constructor(private readonly invoiceService: WarehouseInvoiceService) {} @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) { return this.invoiceService.generateForInventory(id, dto); } @Post('last-mile/:id/generate-truck-detention-invoice') + @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate) @ApiOperation({ summary: 'Generate a truck-detention invoice for a last-mile leg (per truck per day)' }) generateTruckDetention( @Param('id', ParseUUIDPipe) id: string, @@ -28,6 +32,7 @@ export class WarehouseInvoiceController { } @Get('warehouse-inventory/:id/fee-invoices') + @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.view) @ApiOperation({ summary: 'List fee invoices for an inventory item' }) listForInventory(@Param('id', ParseUUIDPipe) id: string) { return this.invoiceService.listForInventory(id); @@ -40,6 +45,7 @@ export class WarehouseInvoiceController { } @Get('warehouse-fee-invoices') + @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.view) @ApiOperation({ summary: 'List / filter warehouse fee invoices' }) findAll( @Query('status') status?: string, @@ -86,12 +92,14 @@ export class WarehouseInvoiceController { } @Patch('warehouse-fee-invoices/:id/cancel') + @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.cancel) @ApiOperation({ summary: 'Cancel a warehouse fee invoice' }) cancel(@Param('id', ParseUUIDPipe) id: string) { return this.invoiceService.cancel(id); } @Post('warehouse-fee-invoices/:id/pay') + @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.pay) @ApiOperation({ summary: 'Record a payment against a warehouse fee invoice' }) pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) { return this.invoiceService.pay(id, dto); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts index aab8e18b2..32860082e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts @@ -1,11 +1,14 @@ import { Controller, Get, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { WarehouseInventoryService } from './warehouse-inventory.service'; @ApiTags('warehouse-loadings') @ApiBearerAuth() @Controller('warehouse-loadings') +@BookingStaff(FREIGHT_PERMS.warehouseInventory.view) export class WarehouseLoadingsController { constructor(private readonly inventoryService: WarehouseInventoryService) {} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts index 19788e027..7a5c53d38 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts @@ -1,6 +1,8 @@ import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { AllocationPreviewDto, CreateAllocationRuleDto, @@ -22,18 +24,21 @@ export class WarehouseRulesController { // ── Allocation rules ─────────────────────────────────────────────────────── @Get('warehouse-allocation-rules') + @BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.view) @ApiOperation({ summary: 'List warehouse allocation rules' }) listAllocationRules() { return this.allocationService.listRules(); } @Post('warehouse-allocation-rules') + @BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.create) @ApiOperation({ summary: 'Create a warehouse allocation rule' }) createAllocationRule(@Body() dto: CreateAllocationRuleDto) { return this.allocationService.createRule(dto); } @Patch('warehouse-allocation-rules/:id') + @BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.update) @ApiOperation({ summary: 'Update a warehouse allocation rule' }) updateAllocationRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateAllocationRuleDto) { return this.allocationService.updateRule(id, dto); @@ -41,12 +46,14 @@ export class WarehouseRulesController { @Delete('warehouse-allocation-rules/:id') @HttpCode(204) + @BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.delete) @ApiOperation({ summary: 'Delete a warehouse allocation rule' }) deleteAllocationRule(@Param('id', ParseUUIDPipe) id: string) { return this.allocationService.deleteRule(id); } @Post('warehouse-allocation/preview') + @BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.view) @ApiOperation({ summary: 'Preview the yard/warehouse/zone a booking would be allocated to' }) previewAllocation(@Body() dto: AllocationPreviewDto) { return this.allocationService.resolveLocation(dto); @@ -54,18 +61,21 @@ export class WarehouseRulesController { // ── Fee rules ──────────────────────────────────────────────────────────────── @Get('warehouse-fee-rules') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view) @ApiOperation({ summary: 'List storage / demurrage fee rules' }) listFeeRules() { return this.feeService.listRules(); } @Post('warehouse-fee-rules') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.create) @ApiOperation({ summary: 'Create a storage / demurrage fee rule' }) createFeeRule(@Body() dto: CreateFeeRuleDto) { return this.feeService.createRule(dto); } @Patch('warehouse-fee-rules/:id') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update) @ApiOperation({ summary: 'Update a fee rule' }) updateFeeRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFeeRuleDto) { return this.feeService.updateRule(id, dto); @@ -73,18 +83,21 @@ export class WarehouseRulesController { @Delete('warehouse-fee-rules/:id') @HttpCode(204) + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.delete) @ApiOperation({ summary: 'Delete a fee rule' }) deleteFeeRule(@Param('id', ParseUUIDPipe) id: string) { return this.feeService.deleteRule(id); } @Get('warehouse-fees/accrual-dashboard') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view) @ApiOperation({ summary: 'Live per-item fee accrual (storage/demurrage) with alerts' }) accrualDashboard(@Query('billingCurrency') billingCurrency?: string) { return this.feeService.accrualDashboard(billingCurrency); } @Post('warehouse-fees/accrual/:inventoryId/acknowledge') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update) @ApiOperation({ summary: 'Acknowledge / snooze an item fee-accrual alert' }) acknowledgeAccrual( @Param('inventoryId', ParseUUIDPipe) inventoryId: string, @@ -98,12 +111,14 @@ export class WarehouseRulesController { @Delete('warehouse-fees/accrual/:inventoryId/acknowledge') @HttpCode(204) + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update) @ApiOperation({ summary: 'Remove an accrual acknowledgement (re-surface for alerts)' }) unacknowledgeAccrual(@Param('inventoryId', ParseUUIDPipe) inventoryId: string) { return this.feeService.unacknowledgeAccrual(inventoryId); } @Get('warehouse-inventory/:id/fee-preview') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view) @ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' }) feePreview( @Param('id', ParseUUIDPipe) id: string, @@ -113,6 +128,7 @@ export class WarehouseRulesController { } @Get('last-mile/:id/truck-detention-preview') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view) @ApiOperation({ summary: 'Preview truck detention for a last-mile leg (per truck per day after grace)' }) truckDetentionPreview( @Param('id', ParseUUIDPipe) id: string, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts index c14cea7a4..fdfbc36be 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts @@ -1,6 +1,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; import { WarehouseYardsService } from './warehouse-yards.service'; @@ -9,6 +11,7 @@ import { WarehouseZonesService } from './warehouse-zones.service'; @ApiTags('warehouse-yards') @ApiBearerAuth() @Controller('warehouse-yards') +@BookingStaff(FREIGHT_PERMS.warehouseYards.view) export class WarehouseYardsController { constructor( private readonly yardsService: WarehouseYardsService, @@ -28,18 +31,21 @@ export class WarehouseYardsController { } @Patch(':id') + @BookingStaff(FREIGHT_PERMS.warehouseYards.update) @ApiOperation({ summary: 'Update warehouse yard' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseYardDto) { return this.yardsService.update(id, dto); } @Get(':yardId/zones') + @BookingStaff(FREIGHT_PERMS.warehouseZones.view) @ApiOperation({ summary: 'List zones within a yard' }) listZones(@Param('yardId', ParseUUIDPipe) yardId: string) { return this.zonesService.findByYard(yardId); } @Post(':yardId/zones') + @BookingStaff(FREIGHT_PERMS.warehouseZones.create) @ApiOperation({ summary: 'Create a zone within a yard' }) createZone( @Param('yardId', ParseUUIDPipe) yardId: string, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts index 7d51feac3..b0371cbcc 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts @@ -1,12 +1,15 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto'; import { WarehouseZonesService } from './warehouse-zones.service'; @ApiTags('warehouse-zones') @ApiBearerAuth() @Controller('warehouse-zones') +@BookingStaff(FREIGHT_PERMS.warehouseZones.view) export class WarehouseZonesController { constructor(private readonly zonesService: WarehouseZonesService) {} @@ -23,6 +26,7 @@ export class WarehouseZonesController { } @Patch(':id') + @BookingStaff(FREIGHT_PERMS.warehouseZones.update) @ApiOperation({ summary: 'Update warehouse zone' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) { return this.zonesService.update(id, dto); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts index bb7702603..63c40de94 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts @@ -1,6 +1,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateWarehouseDto } from './dto/create-warehouse.dto'; import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; import { FilterWarehouseDto } from './dto/filter-warehouse.dto'; @@ -12,6 +14,7 @@ import { WarehousesService } from './warehouses.service'; @ApiTags('warehouses') @ApiBearerAuth() @Controller('warehouses') +@BookingStaff(FREIGHT_PERMS.warehouses.view) export class WarehousesController { constructor( private readonly warehousesService: WarehousesService, @@ -26,12 +29,14 @@ export class WarehousesController { } @Get('dashboard') + @BookingStaff(FREIGHT_PERMS.warehouseDashboard.view) @ApiOperation({ summary: 'Warehouse dashboard metrics' }) dashboard() { return this.dashboardService.getDashboard(); } @Post() + @BookingStaff(FREIGHT_PERMS.warehouses.create) @ApiOperation({ summary: 'Create warehouse' }) create(@Body() dto: CreateWarehouseDto) { return this.warehousesService.create(dto); @@ -44,18 +49,21 @@ export class WarehousesController { } @Patch(':id') + @BookingStaff(FREIGHT_PERMS.warehouses.update) @ApiOperation({ summary: 'Update warehouse' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseDto) { return this.warehousesService.update(id, dto); } @Get(':warehouseId/yards') + @BookingStaff(FREIGHT_PERMS.warehouseYards.view) @ApiOperation({ summary: 'List yards within a warehouse' }) listYards(@Param('warehouseId', ParseUUIDPipe) warehouseId: string) { return this.yardsService.findByWarehouse(warehouseId); } @Post(':warehouseId/yards') + @BookingStaff(FREIGHT_PERMS.warehouseYards.create) @ApiOperation({ summary: 'Create a yard within a warehouse' }) createYard( @Param('warehouseId', ParseUUIDPipe) warehouseId: string,