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

@@ -65,6 +65,7 @@ import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
@@ -198,6 +199,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
href: "/dashboard/inventory-inquiry",
icon: <Boxes />,
},
{
label: "Allocation & Fees",
href: "/dashboard/warehouse-rules",
icon: <SlidersHorizontal />,
},
],
},
{
@@ -368,6 +374,7 @@ const App = () => {
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
<Route

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';

View File

@@ -312,4 +312,13 @@ export const URL_CONSTANTS = {
BY_ID: (id: string) => `/warehouse-inspection-reports/${id}`,
ATTACHMENTS: (id: string) => `/warehouse-inspection-reports/${id}/attachments`,
},
WAREHOUSE_RULES: {
ALLOCATION: '/warehouse-allocation-rules',
ALLOCATION_BY_ID: (id: string) => `/warehouse-allocation-rules/${id}`,
ALLOCATION_PREVIEW: '/warehouse-allocation/preview',
FEES: '/warehouse-fee-rules',
FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`,
FEE_PREVIEW: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-preview`,
},
};

View File

@@ -3,6 +3,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { warehouseService } from '@/services/warehouse.service';
import type {
InspectionReportPayload,
SaveAllocationRulePayload,
SaveFeeRulePayload,
InventoryFilter,
InventoryInquiryFilter,
LoadInventoryPayload,
@@ -286,3 +288,60 @@ export function useUploadInspectionAttachments() {
warehouseService.uploadInspectionAttachments(reportId, files),
});
}
// ── Batch 5: Allocation + Fee rules / preview ───────────────────────────────
export function useAllocationRules() {
return useQuery({
queryKey: ['warehouse-allocation-rules'],
queryFn: () => warehouseService.listAllocationRules().then((r) => r.data),
});
}
export function useFeeRules() {
return useQuery({
queryKey: ['warehouse-fee-rules'],
queryFn: () => warehouseService.listFeeRules().then((r) => r.data),
});
}
function useRuleMutation<TArgs>(fn: (args: TArgs) => Promise<unknown>, keys: string[]) {
const qc = useQueryClient();
return useMutation({
mutationFn: fn,
onSuccess: () => keys.forEach((k) => qc.invalidateQueries({ queryKey: [k] })),
});
}
export const useCreateAllocationRule = () =>
useRuleMutation(
(payload: SaveAllocationRulePayload) => warehouseService.createAllocationRule(payload),
['warehouse-allocation-rules'],
);
export const useUpdateAllocationRule = () =>
useRuleMutation(
(args: { id: string; payload: Partial<SaveAllocationRulePayload> }) =>
warehouseService.updateAllocationRule(args.id, args.payload),
['warehouse-allocation-rules'],
);
export const useDeleteAllocationRule = () =>
useRuleMutation((id: string) => warehouseService.deleteAllocationRule(id), ['warehouse-allocation-rules']);
export const useCreateFeeRule = () =>
useRuleMutation((payload: SaveFeeRulePayload) => warehouseService.createFeeRule(payload), ['warehouse-fee-rules']);
export const useUpdateFeeRule = () =>
useRuleMutation(
(args: { id: string; payload: Partial<SaveFeeRulePayload> }) =>
warehouseService.updateFeeRule(args.id, args.payload),
['warehouse-fee-rules'],
);
export const useDeleteFeeRule = () =>
useRuleMutation((id: string) => warehouseService.deleteFeeRule(id), ['warehouse-fee-rules']);
export function useFeePreview(inventoryId?: string) {
return useQuery({
queryKey: ['warehouse-inventory', inventoryId, 'fee-preview'],
queryFn: () => warehouseService.feePreview(inventoryId as string).then((r) => r.data),
enabled: Boolean(inventoryId),
});
}

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

View File

@@ -2,12 +2,19 @@ import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
import type {
AllocationCriteria,
AllocationPreviewResult,
AllocationRule,
ArrivalQueueItem,
AutoLoadResult,
AutoUnloadResult,
FeePreview,
FeeRule,
InspectionAttachment,
InspectionReport,
InspectionReportPayload,
SaveAllocationRulePayload,
SaveFeeRulePayload,
BookingScheduleView,
InventoryFilter,
InventoryInquiryFilter,
@@ -145,4 +152,25 @@ export const warehouseService = {
{ headers: { 'Content-Type': 'multipart/form-data' } },
);
},
// ── Batch 5: Allocation + Fee rules / previews ─────────────────────────────
listAllocationRules: () =>
apiClient.get<AllocationRule[]>(URL_CONSTANTS.WAREHOUSE_RULES.ALLOCATION),
createAllocationRule: (payload: SaveAllocationRulePayload) =>
apiClient.post<AllocationRule>(URL_CONSTANTS.WAREHOUSE_RULES.ALLOCATION, payload),
updateAllocationRule: (id: string, payload: Partial<SaveAllocationRulePayload>) =>
apiClient.patch<AllocationRule>(URL_CONSTANTS.WAREHOUSE_RULES.ALLOCATION_BY_ID(id), payload),
deleteAllocationRule: (id: string) =>
apiClient.delete(URL_CONSTANTS.WAREHOUSE_RULES.ALLOCATION_BY_ID(id)),
previewAllocation: (criteria: AllocationCriteria) =>
apiClient.post<AllocationPreviewResult | null>(URL_CONSTANTS.WAREHOUSE_RULES.ALLOCATION_PREVIEW, criteria),
listFeeRules: () => apiClient.get<FeeRule[]>(URL_CONSTANTS.WAREHOUSE_RULES.FEES),
createFeeRule: (payload: SaveFeeRulePayload) =>
apiClient.post<FeeRule>(URL_CONSTANTS.WAREHOUSE_RULES.FEES, payload),
updateFeeRule: (id: string, payload: Partial<SaveFeeRulePayload>) =>
apiClient.patch<FeeRule>(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id), payload),
deleteFeeRule: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id)),
feePreview: (inventoryId: string) =>
apiClient.get<FeePreview[]>(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId)),
};

View File

@@ -359,6 +359,81 @@ export interface InspectionReport extends InspectionReportPayload {
attachments?: InspectionAttachment[];
}
// ── Batch 5: Allocation + Fee rules / preview ───────────────────────────────
export interface AllocationRule {
id: string;
name: string;
priority: number;
freightType?: string | null;
tradeDirection?: string | null;
cargoTypeCode?: string | null;
containerStatus?: string | null;
requiresInspection?: boolean | null;
targetFacilityCode?: string | null;
targetYardCode: string;
targetWarehouseCode?: string | null;
targetZoneCode?: string | null;
storageType?: string | null;
isActive: boolean;
}
export type SaveAllocationRulePayload = Omit<AllocationRule, 'id'>;
export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const;
export type FeeRuleType = (typeof FEE_RULE_TYPES)[number];
export interface FeeRule {
id: string;
name: string;
ruleType: FeeRuleType;
priority: number;
freightType?: string | null;
tradeDirection?: string | null;
cargoTypeCode?: string | null;
containerType?: string | null;
facilityId?: string | null;
warehouseId?: string | null;
yardId?: string | null;
zoneId?: string | null;
freeDays: number;
ratePerDay: number;
currency: string;
isActive: boolean;
}
export type SaveFeeRulePayload = Omit<FeeRule, 'id'>;
export interface FeePreview {
ruleType: FeeRuleType;
ruleId: string | null;
ruleName: string | null;
freeDays: number;
ratePerDay: number;
currency: string;
startDate: string | null;
endDate: string;
endIsOpen: boolean;
elapsedDays: number;
chargeableDays: number;
amount: number;
}
export interface AllocationPreviewResult {
warehouseId: string;
yardId: string;
zoneId: string;
facilityId: string | null;
rule: { id: string; name: string; storageType: string | null } | null;
path: string;
}
export interface AllocationCriteria {
freightType?: string;
tradeDirection?: string;
cargoTypeCode?: string;
containerStatus?: string;
requiresInspection?: boolean;
}
// ── Payloads ───────────────────────────────────────────────────────────────
export interface SaveWarehousePayload {