mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
fee invoice frontend
This commit is contained in:
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 & 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>
|
||||
)}
|
||||
|
||||
@@ -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`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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), {}),
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user