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