mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 06:40:57 +00:00
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:
@@ -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 & 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 & payment are handled in Batch 6. Charges accrue from arrival until
|
||||
gate clearance / release (or today if still in terminal).
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -26,3 +26,4 @@ export { WarehouseHero } from './WarehouseHero';
|
||||
export { VisualEmptyState } from './VisualEmptyState';
|
||||
export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
|
||||
export { InspectionReportModal } from './InspectionReportModal';
|
||||
export { FeePreviewModal } from './FeePreviewModal';
|
||||
|
||||
Reference in New Issue
Block a user