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

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