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,285 @@
import { useState } from 'react';
import {
ActionIcon,
Badge,
Button,
Card,
Container,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Table,
Tabs,
Text,
TextInput,
} from '@mantine/core';
import { Plus, Trash2 } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { WarehouseHero } from '@/components/warehouses';
import { useToast } from '@/hooks/use-toast';
import {
useAllocationRules,
useCreateAllocationRule,
useCreateFeeRule,
useDeleteAllocationRule,
useDeleteFeeRule,
useFeeRules,
} from '@/hooks/useWarehouses';
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
const FREIGHT = [
{ value: 'CONTAINER', label: 'Container' },
{ value: 'BULK', label: 'Bulk' },
];
const TRADE = [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
];
const clean = (s: string) => s.trim() || undefined;
export default function WarehouseRulesPage() {
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouse rules' }]} />
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="warehouse"
secondaryVariant="container"
title="Allocation & Fee Rules"
subtitle="Configure deterministic yard allocation and storage / demurrage free time and rates."
/>
<Card withBorder radius="md" padding="lg">
<Tabs defaultValue="allocation">
<Tabs.List>
<Tabs.Tab value="allocation">Allocation Rules</Tabs.Tab>
<Tabs.Tab value="fees">Storage / Demurrage Fees</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="allocation" pt="md">
<AllocationRules />
</Tabs.Panel>
<Tabs.Panel value="fees" pt="md">
<FeeRules />
</Tabs.Panel>
</Tabs>
</Card>
</Stack>
</Container>
);
}
function AllocationRules() {
const { toast } = useToast();
const { data, isLoading } = useAllocationRules();
const create = useCreateAllocationRule();
const remove = useDeleteAllocationRule();
const [open, setOpen] = useState(false);
const [form, setForm] = useState({
name: '',
priority: 100,
freightType: '',
tradeDirection: '',
cargoTypeCode: '',
containerStatus: '',
targetYardCode: '',
storageType: '',
});
const rules = data ?? [];
const submit = async () => {
if (!form.name.trim() || !form.targetYardCode.trim()) {
toast({ variant: 'destructive', title: 'Name and target yard code are required' });
return;
}
await create.mutateAsync({
name: form.name.trim(),
priority: form.priority,
freightType: clean(form.freightType) ?? null,
tradeDirection: clean(form.tradeDirection) ?? null,
cargoTypeCode: clean(form.cargoTypeCode) ?? null,
containerStatus: clean(form.containerStatus) ?? null,
targetYardCode: form.targetYardCode.trim(),
storageType: clean(form.storageType) ?? null,
isActive: true,
} as never);
toast({ title: 'Allocation rule created' });
setOpen(false);
setForm({ name: '', priority: 100, freightType: '', tradeDirection: '', cargoTypeCode: '', containerStatus: '', targetYardCode: '', storageType: '' });
};
return (
<>
<Group justify="space-between" mb="sm">
<Text c="dimmed" size="sm">{rules.length} rule(s) matched by ascending priority</Text>
<Button color="orange" leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New allocation rule</Button>
</Group>
{isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<Table.ScrollContainer minWidth={900}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Priority</Table.Th><Table.Th>Name</Table.Th><Table.Th>Freight</Table.Th>
<Table.Th>Trade</Table.Th><Table.Th>Cargo code</Table.Th><Table.Th>Target yard</Table.Th>
<Table.Th>Active</Table.Th><Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rules.map((r) => (
<Table.Tr key={r.id}>
<Table.Td>{r.priority}</Table.Td>
<Table.Td>{r.name}</Table.Td>
<Table.Td>{r.freightType ?? '—'}</Table.Td>
<Table.Td>{r.tradeDirection ?? '—'}</Table.Td>
<Table.Td>{r.cargoTypeCode ?? '—'}</Table.Td>
<Table.Td><Badge variant="light">{r.targetYardCode}</Badge></Table.Td>
<Table.Td><Badge color={r.isActive ? 'green' : 'gray'} variant="light">{r.isActive ? 'Yes' : 'No'}</Badge></Table.Td>
<Table.Td ta="right">
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(r.id)} title="Delete">
<Trash2 size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg">
<Stack gap="sm">
<Group grow>
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} />
<NumberInput label="Priority" value={form.priority} onChange={(v) => setForm((f) => ({ ...f, priority: Number(v) || 100 }))} />
</Group>
<Group grow>
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable />
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable />
</Group>
<Group grow>
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} />
<TextInput label="Container status" placeholder="e.g. MAINTENANCE" value={form.containerStatus} onChange={(e) => setForm((f) => ({ ...f, containerStatus: e.currentTarget.value }))} />
</Group>
<Group grow>
<TextInput label="Target yard code" required value={form.targetYardCode} onChange={(e) => setForm((f) => ({ ...f, targetYardCode: e.currentTarget.value }))} />
<TextInput label="Storage type" value={form.storageType} onChange={(e) => setForm((f) => ({ ...f, storageType: e.currentTarget.value }))} />
</Group>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button>
<Button color="orange" loading={create.isPending} onClick={submit}>Create</Button>
</Group>
</Stack>
</Modal>
</>
);
}
function FeeRules() {
const { toast } = useToast();
const { data, isLoading } = useFeeRules();
const create = useCreateFeeRule();
const remove = useDeleteFeeRule();
const [open, setOpen] = useState(false);
const [form, setForm] = useState({
name: '',
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
freightType: '',
tradeDirection: '',
cargoTypeCode: '',
freeDays: 3,
ratePerDay: 0,
currency: 'USD',
});
const rules = data ?? [];
const submit = async () => {
if (!form.name.trim()) {
toast({ variant: 'destructive', title: 'Name is required' });
return;
}
await create.mutateAsync({
name: form.name.trim(),
ruleType: form.ruleType,
freightType: clean(form.freightType) ?? null,
tradeDirection: clean(form.tradeDirection) ?? null,
cargoTypeCode: clean(form.cargoTypeCode) ?? null,
freeDays: form.freeDays,
ratePerDay: form.ratePerDay,
currency: form.currency || 'USD',
isActive: true,
} as never);
toast({ title: 'Fee rule created' });
setOpen(false);
};
return (
<>
<Group justify="space-between" mb="sm">
<Text c="dimmed" size="sm">{rules.length} rule(s) most specific match applies</Text>
<Button color="teal" leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New fee rule</Button>
</Group>
{isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<Table.ScrollContainer minWidth={900}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th><Table.Th>Name</Table.Th><Table.Th>Freight</Table.Th>
<Table.Th>Trade</Table.Th><Table.Th>Free days</Table.Th><Table.Th>Rate / day</Table.Th>
<Table.Th>Active</Table.Th><Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rules.map((r) => (
<Table.Tr key={r.id}>
<Table.Td><Badge color={r.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light">{r.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'}</Badge></Table.Td>
<Table.Td>{r.name}</Table.Td>
<Table.Td>{r.freightType ?? '—'}</Table.Td>
<Table.Td>{r.tradeDirection ?? '—'}</Table.Td>
<Table.Td>{r.freeDays}</Table.Td>
<Table.Td>{Number(r.ratePerDay).toLocaleString()} {r.currency}</Table.Td>
<Table.Td><Badge color={r.isActive ? 'green' : 'gray'} variant="light">{r.isActive ? 'Yes' : 'No'}</Badge></Table.Td>
<Table.Td ta="right">
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(r.id)} title="Delete">
<Trash2 size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg">
<Stack gap="sm">
<Group grow>
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} />
<Select label="Rule type" data={FEE_RULE_TYPES.map((t) => ({ value: t, label: t === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage' }))} value={form.ruleType} onChange={(v) => setForm((f) => ({ ...f, ruleType: (v as FeeRuleType) ?? 'DEMURRAGE_FEE' }))} allowDeselect={false} />
</Group>
<Group grow>
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable />
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable />
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} />
</Group>
<Group grow>
<NumberInput label="Free days" min={0} value={form.freeDays} onChange={(v) => setForm((f) => ({ ...f, freeDays: Number(v) || 0 }))} />
<NumberInput label="Rate / day" min={0} value={form.ratePerDay} onChange={(v) => setForm((f) => ({ ...f, ratePerDay: Number(v) || 0 }))} />
<TextInput label="Currency" value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.currentTarget.value }))} />
</Group>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button>
<Button color="teal" loading={create.isPending} onClick={submit}>Create</Button>
</Group>
</Stack>
</Modal>
</>
);
}