From d9fe79e16021772bd20072c7afd17896e76ace2d Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 14 Jul 2026 08:07:46 +0000 Subject: [PATCH] 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;