mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +00:00
accrual dashboard for port and terminal or warehouse related documents
This commit is contained in:
@@ -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;
|
||||
}>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user