fee invoice frontend

This commit is contained in:
Hagernesh
2026-06-18 00:12:59 +00:00
parent 3860d17304
commit be12a9d496
8 changed files with 520 additions and 8 deletions

View File

@@ -86,6 +86,8 @@ import { WarehousesService } from './warehouses.service';
WarehouseInspectionRepository,
WarehouseAllocationRuleRepository,
WarehouseFeeRuleRepository,
WarehouseFeeInvoiceRepository,
WarehouseFeeInvoiceItemRepository,
WarehousesService,
WarehouseYardsService,
WarehouseZonesService,
@@ -95,6 +97,7 @@ import { WarehousesService } from './warehouses.service';
WarehouseInspectionService,
WarehouseAllocationService,
WarehouseFeeService,
WarehouseInvoiceService,
WarehouseSchedulingAdapterService,
SchedulingReadFacade,
],

View File

@@ -66,6 +66,7 @@ import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
@@ -204,6 +205,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
href: "/dashboard/warehouse-rules",
icon: <SlidersHorizontal />,
},
{
label: "Fee Invoices",
href: "/dashboard/warehouse-fee-invoices",
icon: <Wallet />,
},
],
},
{
@@ -375,6 +381,7 @@ const App = () => {
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
<Route

View File

@@ -1,8 +1,23 @@
import { Badge, Card, Group, Loader, Modal, Stack, Text } from '@mantine/core';
import { CalendarClock, Coins } from 'lucide-react';
import { Badge, Button, Card, Divider, Group, Loader, Modal, Stack, Text } from '@mantine/core';
import { CalendarClock, Coins, DoorOpen, FileText } from 'lucide-react';
import { useFeePreview } from '@/hooks/useWarehouses';
import type { FeePreview } from '@/types/warehouse';
import { useToast } from '@/hooks/use-toast';
import {
useFeePreview,
useGateClearance,
useGenerateInvoice,
useInvoicesForInventory,
} from '@/hooks/useWarehouses';
import { extractErrorMessage } from './options';
import type { FeePreview, WarehouseInvoiceStatus } from '@/types/warehouse';
const INVOICE_STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
DRAFT: 'gray',
ISSUED: 'orange',
PARTIALLY_PAID: 'yellow',
PAID: 'green',
CANCELLED: 'gray',
};
interface FeePreviewModalProps {
opened: boolean;
@@ -69,9 +84,44 @@ function Row({ label, value }: { label: string; value: string }) {
);
}
/** Batch 5 — automatic storage/demurrage fee preview for an inventory item (no invoice/payment). */
/** Batch 5 fee preview + Batch 6 invoice generation / gate clearance for an inventory item. */
export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModalProps) {
const { data, isLoading } = useFeePreview(opened ? inventoryId ?? undefined : undefined);
const { toast } = useToast();
const enabledId = opened ? inventoryId ?? undefined : undefined;
const { data, isLoading } = useFeePreview(enabledId);
const { data: invoices } = useInvoicesForInventory(enabledId);
const generate = useGenerateInvoice();
const gateClear = useGateClearance();
const activeInvoice = (invoices ?? []).find((i) => i.status !== 'CANCELLED');
const handleGenerate = async (confirmZero = false) => {
if (!inventoryId) return;
try {
const inv = await generate.mutateAsync({ inventoryId, confirmZero });
toast({ title: 'Invoice generated', description: `${inv.invoiceNumber}${inv.totalAmount} ${inv.currency}` });
} catch (error) {
const msg = extractErrorMessage(error);
if (/no payable warehouse fee/i.test(msg)) {
if (window.confirm('No payable warehouse fee found. Create a zero-amount invoice anyway?')) {
handleGenerate(true);
}
return;
}
toast({ variant: 'destructive', title: 'Generate failed', description: msg });
}
};
const handleGateClearance = async () => {
if (!inventoryId) return;
try {
await gateClear.mutateAsync(inventoryId);
toast({ title: 'Gate clearance recorded', description: 'Item released from terminal.' });
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Release blocked', description: extractErrorMessage(error) });
}
};
return (
<Modal
@@ -95,9 +145,47 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
{(data ?? []).map((fee) => (
<FeeCard key={fee.ruleType} fee={fee} />
))}
<Divider label="Invoice & Release" labelPosition="left" />
{activeInvoice ? (
<Group justify="space-between">
<Group gap="xs">
<FileText size={16} />
<Text size="sm" fw={600}>{activeInvoice.invoiceNumber}</Text>
<Badge variant="light" color={INVOICE_STATUS_COLOR[activeInvoice.status]}>
{activeInvoice.status.replace(/_/g, ' ')}
</Badge>
</Group>
<Text size="sm">
{Number(activeInvoice.balanceAmount).toLocaleString()} {activeInvoice.currency} due
</Text>
</Group>
) : (
<Button
variant="light"
color="orange"
leftSection={<FileText size={16} />}
loading={generate.isPending}
onClick={() => handleGenerate(false)}
>
Generate Fee Invoice
</Button>
)}
<Button
variant="light"
color="green"
leftSection={<DoorOpen size={16} />}
loading={gateClear.isPending}
onClick={handleGateClearance}
>
Gate Clearance / Release
</Button>
<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).
Charges accrue from arrival until gate clearance / release. Final release is blocked while a
demurrage/storage invoice is unpaid.
</Text>
</Stack>
)}

View File

@@ -321,4 +321,15 @@ export const URL_CONSTANTS = {
FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`,
FEE_PREVIEW: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-preview`,
},
WAREHOUSE_INVOICES: {
BASE: '/warehouse-fee-invoices',
BY_ID: (id: string) => `/warehouse-fee-invoices/${id}`,
CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`,
PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`,
GENERATE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/generate-fee-invoice`,
FOR_INVENTORY: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-invoices`,
FOR_BOOKING: (bookingId: string) => `/bookings/${bookingId}/warehouse-fee-invoices`,
GATE_CLEARANCE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/gate-clearance`,
},
};

View File

@@ -5,6 +5,8 @@ import type {
InspectionReportPayload,
SaveAllocationRulePayload,
SaveFeeRulePayload,
WarehouseInvoiceFilter,
PayInvoicePayload,
InventoryFilter,
InventoryInquiryFilter,
LoadInventoryPayload,
@@ -345,3 +347,64 @@ export function useFeePreview(inventoryId?: string) {
enabled: Boolean(inventoryId),
});
}
// ── Batch 6: Warehouse fee invoices ─────────────────────────────────────────
export function useWarehouseInvoices(filter?: WarehouseInvoiceFilter) {
return useQuery({
queryKey: ['warehouse-fee-invoices', filter ?? {}],
queryFn: () => warehouseService.listInvoices(filter).then((r) => r.data),
});
}
export function useWarehouseInvoice(id?: string) {
return useQuery({
queryKey: ['warehouse-fee-invoices', 'detail', id],
queryFn: () => warehouseService.getInvoice(id as string).then((r) => r.data),
enabled: Boolean(id),
});
}
export function useInvoicesForInventory(inventoryId?: string) {
return useQuery({
queryKey: ['warehouse-inventory', inventoryId, 'fee-invoices'],
queryFn: () => warehouseService.invoicesForInventory(inventoryId as string).then((r) => r.data),
enabled: Boolean(inventoryId),
});
}
function useInvoiceInvalidation() {
const qc = useQueryClient();
return () => {
qc.invalidateQueries({ queryKey: ['warehouse-fee-invoices'] });
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
};
}
export function useGenerateInvoice() {
const onSuccess = useInvoiceInvalidation();
return useMutation({
mutationFn: ({ inventoryId, confirmZero }: { inventoryId: string; confirmZero?: boolean }) =>
warehouseService.generateInvoice(inventoryId, confirmZero).then((r) => r.data),
onSuccess,
});
}
export function useCancelInvoice() {
const onSuccess = useInvoiceInvalidation();
return useMutation({ mutationFn: (id: string) => warehouseService.cancelInvoice(id), onSuccess });
}
export function usePayInvoice() {
const onSuccess = useInvoiceInvalidation();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: PayInvoicePayload }) =>
warehouseService.payInvoice(id, payload),
onSuccess,
});
}
export function useGateClearance() {
const onSuccess = useInvoiceInvalidation();
return useMutation({ mutationFn: (inventoryId: string) => warehouseService.gateClearance(inventoryId), onSuccess });
}

View File

@@ -0,0 +1,238 @@
import { useMemo, useState } from 'react';
import {
ActionIcon,
Badge,
Button,
Card,
Container,
Divider,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Table,
Text,
TextInput,
} from '@mantine/core';
import { Ban, CreditCard, Eye, Search } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { WarehouseHero } from '@/components/warehouses';
import { useToast } from '@/hooks/use-toast';
import {
useCancelInvoice,
usePayInvoice,
useWarehouseInvoice,
useWarehouseInvoices,
} from '@/hooks/useWarehouses';
import {
WAREHOUSE_INVOICE_STATUSES,
type WarehouseFeeInvoice,
type WarehouseInvoiceStatus,
} from '@/types/warehouse';
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
DRAFT: 'gray',
ISSUED: 'orange',
PARTIALLY_PAID: 'yellow',
PAID: 'green',
CANCELLED: 'gray',
};
const fmt = (n: number, c: string) => `${Number(n).toLocaleString()} ${c}`;
const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
export default function WarehouseInvoicesPage() {
const [status, setStatus] = useState<WarehouseInvoiceStatus | null>(null);
const [search, setSearch] = useState('');
const [detailId, setDetailId] = useState<string | null>(null);
const { data, isLoading } = useWarehouseInvoices(status ? { status } : undefined);
const invoices = data ?? [];
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return invoices;
return invoices.filter((i) => [i.invoiceNumber, i.bookingId, i.customerId].join(' ').toLowerCase().includes(q));
}, [invoices, search]);
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouse fee invoices' }]} />
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="container"
secondaryVariant="warehouse"
title="Warehouse Fee Invoices"
subtitle="Demurrage & storage invoices generated from warehouse fee rules."
/>
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" mb="md" wrap="wrap">
<TextInput
placeholder="Search invoice no / booking / customer"
leftSection={<Search size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={320}
/>
<Select
placeholder="All statuses"
data={WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') }))}
value={status}
onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)}
clearable
w={200}
/>
</Group>
{isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : filtered.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">No invoices found.</Text>
) : (
<Table.ScrollContainer minWidth={1000}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Invoice No</Table.Th><Table.Th>Type</Table.Th><Table.Th>Total</Table.Th>
<Table.Th>Paid</Table.Th><Table.Th>Balance</Table.Th><Table.Th>Status</Table.Th>
<Table.Th>Issued</Table.Th><Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((inv) => (
<Table.Tr key={inv.id}>
<Table.Td><Text fw={600} size="sm">{inv.invoiceNumber}</Text></Table.Td>
<Table.Td>{inv.invoiceType.replace(/_/g, ' ')}</Table.Td>
<Table.Td>{fmt(inv.totalAmount, inv.currency)}</Table.Td>
<Table.Td>{fmt(inv.paidAmount, inv.currency)}</Table.Td>
<Table.Td>{fmt(inv.balanceAmount, inv.currency)}</Table.Td>
<Table.Td><Badge variant="light" color={STATUS_COLOR[inv.status]}>{inv.status.replace(/_/g, ' ')}</Badge></Table.Td>
<Table.Td><Text size="xs">{fmtDate(inv.issuedAt)}</Text></Table.Td>
<Table.Td ta="right">
<ActionIcon variant="subtle" color="gray" onClick={() => setDetailId(inv.id)} title="View">
<Eye size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Card>
</Stack>
<InvoiceDetailModal id={detailId} onClose={() => setDetailId(null)} />
</Container>
);
}
function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) {
const { toast } = useToast();
const { data: inv, isLoading } = useWarehouseInvoice(id ?? undefined);
const pay = usePayInvoice();
const cancel = useCancelInvoice();
const [payAmount, setPayAmount] = useState<number | ''>('');
const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
const handlePay = async () => {
if (!inv || !payAmount) return;
try {
await pay.mutateAsync({ id: inv.id, payload: { amount: Number(payAmount), method: 'MANUAL' } });
toast({ title: 'Payment recorded' });
setPayAmount('');
} catch (e) {
toast({ variant: 'destructive', title: 'Payment failed', description: (e as Error)?.message });
}
};
const handleCancel = async () => {
if (!inv) return;
try {
await cancel.mutateAsync(inv.id);
toast({ title: 'Invoice cancelled' });
onClose();
} catch (e) {
toast({ variant: 'destructive', title: 'Cancel failed', description: (e as Error)?.message });
}
};
return (
<Modal opened={Boolean(id)} onClose={onClose} title="Fee invoice" centered size="lg">
{isLoading || !inv ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<Stack gap="sm">
<Group justify="space-between">
<Text fw={700} size="lg">{inv.invoiceNumber}</Text>
<Badge variant="light" color={STATUS_COLOR[inv.status]} size="lg">{inv.status.replace(/_/g, ' ')}</Badge>
</Group>
<Table withRowBorders={false} verticalSpacing={4}>
<Table.Tbody>
{(inv.items ?? []).map((it) => (
<Table.Tr key={it.id}>
<Table.Td>
<Text size="sm">{it.description}</Text>
<Text size="xs" c="dimmed">{it.feeType.replace(/_/g, ' ')} · {it.chargeableDays ?? 0} day(s) @ {fmt(it.unitRate, it.currency)}</Text>
</Table.Td>
<Table.Td ta="right"><Text fw={600}>{fmt(it.amount, it.currency)}</Text></Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Divider />
<Group justify="space-between"><Text size="sm" c="dimmed">Subtotal</Text><Text>{fmt(inv.subtotalAmount, inv.currency)}</Text></Group>
<Group justify="space-between"><Text size="sm" c="dimmed">Tax</Text><Text>{fmt(inv.taxAmount, inv.currency)}</Text></Group>
<Group justify="space-between"><Text fw={700}>Total</Text><Text fw={700}>{fmt(inv.totalAmount, inv.currency)}</Text></Group>
<Group justify="space-between"><Text size="sm" c="dimmed">Paid</Text><Text>{fmt(inv.paidAmount, inv.currency)}</Text></Group>
<Group justify="space-between"><Text fw={600}>Balance</Text><Text fw={600}>{fmt(inv.balanceAmount, inv.currency)}</Text></Group>
{(inv.payments ?? []).length > 0 && (
<>
<Divider label="Payment history" labelPosition="left" />
{(inv.payments ?? []).map((p, i) => (
<Group key={i} justify="space-between">
<Text size="xs" c="dimmed">{fmtDate(p.paidAt)} · {p.method ?? '—'}{p.reference ? ` · ${p.reference}` : ''}</Text>
<Text size="sm">{fmt(p.amount, inv.currency)}</Text>
</Group>
))}
</>
)}
{canPay && (
<>
<Divider label="Record payment" labelPosition="left" />
<Group align="flex-end">
<NumberInput
label="Amount"
min={0}
value={payAmount}
onChange={(v) => setPayAmount(v === '' ? '' : Number(v))}
style={{ flex: 1 }}
/>
<Button color="green" leftSection={<CreditCard size={16} />} loading={pay.isPending} onClick={handlePay}>
Pay
</Button>
</Group>
</>
)}
<Group justify="flex-end" mt="sm">
{inv.status !== 'PAID' && inv.status !== 'CANCELLED' && (
<Button variant="light" color="red" leftSection={<Ban size={16} />} loading={cancel.isPending} onClick={handleCancel}>
Cancel invoice
</Button>
)}
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -15,6 +15,9 @@ import type {
InspectionReportPayload,
SaveAllocationRulePayload,
SaveFeeRulePayload,
WarehouseFeeInvoice,
WarehouseInvoiceFilter,
PayInvoicePayload,
BookingScheduleView,
InventoryFilter,
InventoryInquiryFilter,
@@ -173,4 +176,24 @@ export const warehouseService = {
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)),
// ── Batch 6: Warehouse fee invoices ────────────────────────────────────────
listInvoices: (filter?: WarehouseInvoiceFilter) =>
apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.BASE, {
params: cleanParams(filter ?? {}),
}),
getInvoice: (id: string) =>
apiClient.get<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.BY_ID(id)),
invoicesForInventory: (inventoryId: string) =>
apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_INVENTORY(inventoryId)),
invoicesForBooking: (bookingId: string) =>
apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_BOOKING(bookingId)),
generateInvoice: (inventoryId: string, confirmZero = false) =>
apiClient.post<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.GENERATE(inventoryId), { confirmZero }),
cancelInvoice: (id: string) =>
apiClient.patch<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.CANCEL(id)),
payInvoice: (id: string, payload: PayInvoicePayload) =>
apiClient.post<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.PAY(id), payload),
gateClearance: (inventoryId: string) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVOICES.GATE_CLEARANCE(inventoryId), {}),
};

View File

@@ -434,6 +434,85 @@ export interface AllocationCriteria {
requiresInspection?: boolean;
}
// ── Batch 6: Warehouse fee invoices ─────────────────────────────────────────
export const WAREHOUSE_INVOICE_STATUSES = [
'DRAFT',
'ISSUED',
'PARTIALLY_PAID',
'PAID',
'CANCELLED',
] as const;
export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number];
export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const;
export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number];
export interface WarehouseInvoicePaymentRecord {
amount: number;
method?: string | null;
reference?: string | null;
paidAt: string;
}
export interface WarehouseFeeInvoiceItem {
id: string;
invoiceId: string;
feeRuleId?: string | null;
feeType: string;
description: string;
quantity: number;
unitRate: number;
amount: number;
currency: string;
chargeableDays?: number | null;
freeDays?: number | null;
}
export interface WarehouseFeeInvoice {
id: string;
invoiceNumber: string;
bookingId?: string | null;
customerId?: string | null;
inventoryId: string;
facilityId?: string | null;
warehouseId?: string | null;
yardId?: string | null;
zoneId?: string | null;
invoiceType: WarehouseInvoiceType;
status: WarehouseInvoiceStatus;
subtotalAmount: number;
taxAmount: number;
totalAmount: number;
paidAmount: number;
balanceAmount: number;
currency: string;
periodStart?: string | null;
periodEnd?: string | null;
issuedAt?: string | null;
dueDate?: string | null;
paidAt?: string | null;
cancelledAt?: string | null;
payments?: WarehouseInvoicePaymentRecord[];
notes?: string | null;
items?: WarehouseFeeInvoiceItem[];
}
export interface WarehouseInvoiceFilter {
status?: WarehouseInvoiceStatus;
invoiceType?: WarehouseInvoiceType;
warehouseId?: string;
facilityId?: string;
customerId?: string;
bookingId?: string;
}
export interface PayInvoicePayload {
amount: number;
method?: string;
reference?: string;
}
// ── Payloads ───────────────────────────────────────────────────────────────
export interface SaveWarehousePayload {