accrual dashboard for port and terminal or warehouse related documents

This commit is contained in:
Hagernesh
2026-07-14 08:07:46 +00:00
parent a542a13893
commit d9fe79e160
15 changed files with 526 additions and 3 deletions

View File

@@ -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<void> {
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<WarehouseFeeRule[]> {
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<AccrualDashboardRow[]> {
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<AccrualDashboardRow> => {
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<FeePreview[]> {
const item = await this.loadItem(inventoryId);

View File

@@ -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) {

View File

@@ -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<string> {
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,

View File

@@ -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(

View File

@@ -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<AccrualAlert, { color: string; label: string }> = {
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 (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
return (
<Stack gap="md">
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="sm">
<StatCard
icon={<DollarSign size={18} />}
label="Accruing now"
value={money(summary.totalAccruing, summary.currency)}
color="edr-green"
/>
<StatCard
icon={<AlertTriangle size={18} />}
label="Charging"
value={summary.charging}
color={summary.charging > 0 ? 'red' : 'gray'}
/>
<StatCard
icon={<Clock size={18} />}
label="Free days ending (≤2d)"
value={summary.atRisk}
color={summary.atRisk > 0 ? 'orange' : 'gray'}
/>
</SimpleGrid>
<Card withBorder radius="md" padding={0}>
{rows.length === 0 ? (
<Text c="dimmed" ta="center" py="xl" size="sm">
No in-warehouse items are accruing fees.
</Text>
) : (
<Table.ScrollContainer minWidth={900}>
<Table verticalSpacing="sm" highlightOnHover striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Location</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Accrued</Table.Th>
<Table.Th>Free days</Table.Th>
<Table.Th>Alert</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => {
const meta = ALERT_META[row.alert];
return (
<Table.Tr key={row.inventoryId}>
<Table.Td>
<Text fw={600} size="sm">
{row.bookingReference ?? row.inventoryId.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td>{row.customerName ?? '—'}</Table.Td>
<Table.Td>
<Text size="sm">
{[row.warehouseCode, row.zoneCode].filter(Boolean).join(' · ') || '—'}
</Text>
</Table.Td>
<Table.Td>
<Badge variant="light" color="gray" size="sm">
{row.status}
</Badge>
</Table.Td>
<Table.Td ta="right">
<Text fw={600} size="sm" c={row.accruedAmount > 0 ? 'red' : undefined}>
{money(row.accruedAmount, row.currency)}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c={row.charging ? 'red' : undefined}>
{freeDaysLabel(row)}
</Text>
</Table.Td>
<Table.Td>
<Badge color={meta.color} variant={row.alert === 'OK' ? 'light' : 'filled'} size="sm">
{meta.label}
</Badge>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Card>
</Stack>
);
}
function StatCard({
icon,
label,
value,
color,
}: {
icon: React.ReactNode;
label: string;
value: React.ReactNode;
color: string;
}) {
return (
<Card withBorder radius="md" padding="md">
<Group gap="sm" wrap="nowrap">
<ThemeIcon color={color} variant="light" size={40} radius="md">
{icon}
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
{label}
</Text>
<Text fw={800} fz={20} lh={1.1} truncate>
{value}
</Text>
</Stack>
</Group>
</Card>
);
}

View File

@@ -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}

View File

@@ -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<string>;
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({
</ActionIcon>
</Tooltip>
)}
{onDownloadBundle && item.grnNumber && (
<Tooltip label="Download document bundle (GRN + gate clearance + handover)" withArrow>
<ActionIcon variant="subtle" color="grape" onClick={() => onDownloadBundle(item)}>
<Download size={16} />
</ActionIcon>
</Tooltip>
)}
{onLastMile && item.booking?.lastMileDeliveryAddress && (
<Tooltip label="Last mile delivery" withArrow>
<ActionIcon variant="subtle" color="blue" onClick={() => onLastMile(item)}>

View File

@@ -31,3 +31,4 @@ export { InspectionReportModal } from './InspectionReportModal';
export { FeePreviewModal } from './FeePreviewModal';
export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap';
export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip';
export { AccrualDashboard } from './AccrualDashboard';

View File

@@ -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);
}

View File

@@ -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: {

View File

@@ -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({

View File

@@ -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."
/>
<Stack gap="xs">
<Text fw={700} size="sm" tt="uppercase" c="dimmed">
Accruing now
</Text>
<AccrualDashboard />
</Stack>
<Card>
<Group justify="space-between" mb="md" wrap="wrap">
<TextInput

View File

@@ -6,6 +6,7 @@ import { URL_CONSTANTS } from '@/constants/URLS';
import type {
ZoneOccupancy,
WarehouseOpsStats,
AccrualDashboardRow,
AllocationCriteria,
AllocationPreviewResult,
AllocationRule,
@@ -425,6 +426,10 @@ export const warehouseService = {
apiClient.get<FeePreview[]>(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId), {
params: cleanParams({ billingCurrency }),
}),
accrualDashboard: (billingCurrency?: 'ETB' | 'USD') =>
apiClient.get<AccrualDashboardRow[]>(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, {
params: cleanParams({ billingCurrency }),
}),
// ── Batch 6: Warehouse fee invoices ────────────────────────────────────────
listInvoices: (filter?: WarehouseInvoiceFilter) =>

View File

@@ -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;
}>;
}

View File

@@ -209,6 +209,20 @@ export const bookingsService = {
);
return data;
},
downloadBookingGrnDocument: async (bookingId: string): Promise<Blob> => {
const { data } = await client.get(
`/api/warehouse-inventory/bookings/${bookingId}/grn-document`,
{ responseType: "blob" },
);
return data;
},
downloadBookingReleaseDocument: async (bookingId: string): Promise<Blob> => {
const { data } = await client.get(
`/api/warehouse-inventory/bookings/${bookingId}/release-document`,
{ responseType: "blob" },
);
return data;
},
tracking: async (id: string): Promise<Freight.IBookingTracking> => {
const { data } = await client.get(`/api/bookings/${id}/tracking`);
return data.data;