mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
accrual dashboard for port and terminal or warehouse related documents
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -31,3 +31,4 @@ export { InspectionReportModal } from './InspectionReportModal';
|
||||
export { FeePreviewModal } from './FeePreviewModal';
|
||||
export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap';
|
||||
export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip';
|
||||
export { AccrualDashboard } from './AccrualDashboard';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user