feat(warehouse): Batch 5 — config-driven allocation + storage/demurrage fee rules

- Allocation rules engine: cargo/container/trade criteria -> deterministic yard/warehouse/zone by code; wired into auto-unload (fallback to default)
- Storage/demurrage fee rules: configurable freeDays + ratePerDay; most-specific match; fee preview per inventory item
- Inventory demurrage timestamps: inspectionStartedAt, inspectionCompletedAt, readyForPickupAt, releaseDate, gateClearedAt
- Migration 1790000000000 (allocation_rules + fee_rules tables + inventory date columns)
- Frontend: Allocation & Fees config page, automatic Fee Preview modal, plumbing/hooks
- No invoice/payment (Batch 6)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-06-17 23:53:04 +00:00
parent 270e39edd6
commit 01aec12ee9
23 changed files with 1429 additions and 12 deletions

View File

@@ -0,0 +1,106 @@
import { Badge, Card, Group, Loader, Modal, Stack, Text } from '@mantine/core';
import { CalendarClock, Coins } from 'lucide-react';
import { useFeePreview } from '@/hooks/useWarehouses';
import type { FeePreview } from '@/types/warehouse';
interface FeePreviewModalProps {
opened: boolean;
onClose: () => void;
inventoryId: string | null;
}
const LABELS: Record<string, { label: string; color: string }> = {
DEMURRAGE_FEE: { label: 'Demurrage', color: 'orange' },
STORAGE_FEE: { label: 'Storage', color: 'teal' },
};
function fmtDate(iso: string | null) {
if (!iso) return '—';
return new Date(iso).toLocaleDateString();
}
function FeeCard({ fee }: { fee: FeePreview }) {
const meta = LABELS[fee.ruleType] ?? { label: fee.ruleType, color: 'gray' };
const configured = Boolean(fee.ruleId);
return (
<Card withBorder radius="md" padding="md" style={{ borderColor: `var(--mantine-color-${meta.color}-3)` }}>
<Group justify="space-between" mb="xs">
<Group gap="xs">
<Coins size={16} />
<Text fw={700}>{meta.label}</Text>
{fee.endIsOpen && (
<Badge size="xs" color={meta.color} variant="light">
accruing
</Badge>
)}
</Group>
<Text fw={800} size="lg" c={`${meta.color}.7`}>
{fee.amount.toLocaleString()} {fee.currency}
</Text>
</Group>
{!configured ? (
<Text size="xs" c="dimmed">
No active {meta.label.toLowerCase()} rule configured amount shown as 0.
</Text>
) : (
<Stack gap={4}>
<Row label="Rule" value={fee.ruleName ?? '—'} />
<Row label="Free days" value={String(fee.freeDays)} />
<Row label="Rate / day" value={`${fee.ratePerDay.toLocaleString()} ${fee.currency}`} />
<Row label="Period" value={`${fmtDate(fee.startDate)}${fmtDate(fee.endDate)}${fee.endIsOpen ? ' (today)' : ''}`} />
<Row label="Elapsed days" value={String(fee.elapsedDays)} />
<Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} />
</Stack>
)}
</Card>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<Group justify="space-between" wrap="nowrap">
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm">{value}</Text>
</Group>
);
}
/** Batch 5 — automatic storage/demurrage fee preview for an inventory item (no invoice/payment). */
export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModalProps) {
const { data, isLoading } = useFeePreview(opened ? inventoryId ?? undefined : undefined);
return (
<Modal
opened={opened}
onClose={onClose}
title={
<Group gap="xs">
<CalendarClock size={18} />
<Text fw={700}>Storage &amp; Demurrage Preview</Text>
</Group>
}
centered
size="md"
>
{isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : (
<Stack gap="md">
{(data ?? []).map((fee) => (
<FeeCard key={fee.ruleType} fee={fee} />
))}
<Text size="xs" c="dimmed">
Preview only invoicing &amp; payment are handled in Batch 6. Charges accrue from arrival until
gate clearance / release (or today if still in terminal).
</Text>
</Stack>
)}
</Modal>
);
}

View File

@@ -8,6 +8,7 @@ import {
useStoreInventory,
} from '@/hooks/useWarehouses';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { FeePreviewModal } from './FeePreviewModal';
import { InspectionReportModal } from './InspectionReportModal';
import { InventoryHistoryModal } from './InventoryHistoryModal';
import { LoadInventoryModal } from './LoadInventoryModal';
@@ -30,6 +31,7 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
const [inspectItem, setInspectItem] = useState<WarehouseInventoryItem | null>(null);
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
const storeMutation = useStoreInventory();
const readyMutation = useMarkReadyForLoading();
@@ -83,6 +85,7 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
onMove={setMoveItem}
onHistory={setHistoryItem}
onInspect={setInspectItem}
onFeePreview={setFeeItem}
/>
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
@@ -102,6 +105,11 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
onClose={() => setInspectItem(null)}
inventoryId={inspectItem?.id ?? null}
/>
<FeePreviewModal
opened={Boolean(feeItem)}
onClose={() => setFeeItem(null)}
inventoryId={feeItem?.id ?? null}
/>
</>
);
}

View File

@@ -1,5 +1,5 @@
import { ActionIcon, Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, ClipboardList, History } from 'lucide-react';
import { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { INVENTORY_NEXT_ACTION } from '@/types/warehouse';
@@ -13,6 +13,7 @@ interface WarehouseInventoryTableProps {
onMove: (item: WarehouseInventoryItem) => void;
onHistory: (item: WarehouseInventoryItem) => void;
onInspect?: (item: WarehouseInventoryItem) => void;
onFeePreview?: (item: WarehouseInventoryItem) => void;
}
const itemKind = (item: WarehouseInventoryItem) => {
@@ -37,6 +38,7 @@ export function WarehouseInventoryTable({
onMove,
onHistory,
onInspect,
onFeePreview,
}: WarehouseInventoryTableProps) {
if (items.length === 0) {
return (
@@ -128,6 +130,13 @@ export function WarehouseInventoryTable({
</ActionIcon>
</Tooltip>
)}
{onFeePreview && (
<Tooltip label="Storage / Demurrage preview" withArrow>
<ActionIcon variant="subtle" color="teal" onClick={() => onFeePreview(item)}>
<Coins size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="History" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
<History size={16} />

View File

@@ -26,3 +26,4 @@ export { WarehouseHero } from './WarehouseHero';
export { VisualEmptyState } from './VisualEmptyState';
export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
export { InspectionReportModal } from './InspectionReportModal';
export { FeePreviewModal } from './FeePreviewModal';