diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts
index 60ecc260a..4a08d7f28 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts
@@ -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,
],
diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx
index f60fbba63..0c61cc445 100644
--- a/apps/edr-freight-web/backoffice/src/App.tsx
+++ b/apps/edr-freight-web/backoffice/src/App.tsx
@@ -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: ,
},
+ {
+ label: "Fee Invoices",
+ href: "/dashboard/warehouse-fee-invoices",
+ icon: ,
+ },
],
},
{
@@ -375,6 +381,7 @@ const App = () => {
} />
} />
} />
+ } />
} />
= {
+ 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 (
(
))}
+
+
+
+ {activeInvoice ? (
+
+
+
+ {activeInvoice.invoiceNumber}
+
+ {activeInvoice.status.replace(/_/g, ' ')}
+
+
+
+ {Number(activeInvoice.balanceAmount).toLocaleString()} {activeInvoice.currency} due
+
+
+ ) : (
+ }
+ loading={generate.isPending}
+ onClick={() => handleGenerate(false)}
+ >
+ Generate Fee Invoice
+
+ )}
+
+ }
+ loading={gateClear.isPending}
+ onClick={handleGateClearance}
+ >
+ Gate Clearance / Release
+
+
- 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.
)}
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index 7c3c600e5..ab023d1b3 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -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`,
+ },
};
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
index 639e0dc16..afd34d2b7 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
@@ -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 });
+}
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx
new file mode 100644
index 000000000..3f680bf0f
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx
@@ -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 = {
+ 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(null);
+ const [search, setSearch] = useState('');
+ const [detailId, setDetailId] = useState(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 (
+
+
+
+
+
+
+
+ }
+ value={search}
+ onChange={(e) => setSearch(e.currentTarget.value)}
+ w={320}
+ />
+
+
+ {isLoading ? (
+
+ ) : filtered.length === 0 ? (
+ No invoices found.
+ ) : (
+
+
+
+
+ Invoice NoTypeTotal
+ PaidBalanceStatus
+ IssuedActions
+
+
+
+ {filtered.map((inv) => (
+
+ {inv.invoiceNumber}
+ {inv.invoiceType.replace(/_/g, ' ')}
+ {fmt(inv.totalAmount, inv.currency)}
+ {fmt(inv.paidAmount, inv.currency)}
+ {fmt(inv.balanceAmount, inv.currency)}
+ {inv.status.replace(/_/g, ' ')}
+ {fmtDate(inv.issuedAt)}
+
+ setDetailId(inv.id)} title="View">
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ setDetailId(null)} />
+
+ );
+}
+
+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('');
+
+ 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 (
+
+ {isLoading || !inv ? (
+
+ ) : (
+
+
+ {inv.invoiceNumber}
+ {inv.status.replace(/_/g, ' ')}
+
+
+
+
+ {(inv.items ?? []).map((it) => (
+
+
+ {it.description}
+ {it.feeType.replace(/_/g, ' ')} · {it.chargeableDays ?? 0} day(s) @ {fmt(it.unitRate, it.currency)}
+
+ {fmt(it.amount, it.currency)}
+
+ ))}
+
+
+
+
+ Subtotal{fmt(inv.subtotalAmount, inv.currency)}
+ Tax{fmt(inv.taxAmount, inv.currency)}
+ Total{fmt(inv.totalAmount, inv.currency)}
+ Paid{fmt(inv.paidAmount, inv.currency)}
+ Balance{fmt(inv.balanceAmount, inv.currency)}
+
+ {(inv.payments ?? []).length > 0 && (
+ <>
+
+ {(inv.payments ?? []).map((p, i) => (
+
+ {fmtDate(p.paidAt)} · {p.method ?? '—'}{p.reference ? ` · ${p.reference}` : ''}
+ {fmt(p.amount, inv.currency)}
+
+ ))}
+ >
+ )}
+
+ {canPay && (
+ <>
+
+
+ setPayAmount(v === '' ? '' : Number(v))}
+ style={{ flex: 1 }}
+ />
+ } loading={pay.isPending} onClick={handlePay}>
+ Pay
+
+
+ >
+ )}
+
+
+ {inv.status !== 'PAID' && inv.status !== 'CANCELLED' && (
+ } loading={cancel.isPending} onClick={handleCancel}>
+ Cancel invoice
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
index 62e2591c0..f40d604b9 100644
--- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
@@ -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(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId)),
+
+ // ── Batch 6: Warehouse fee invoices ────────────────────────────────────────
+ listInvoices: (filter?: WarehouseInvoiceFilter) =>
+ apiClient.get(URL_CONSTANTS.WAREHOUSE_INVOICES.BASE, {
+ params: cleanParams(filter ?? {}),
+ }),
+ getInvoice: (id: string) =>
+ apiClient.get(URL_CONSTANTS.WAREHOUSE_INVOICES.BY_ID(id)),
+ invoicesForInventory: (inventoryId: string) =>
+ apiClient.get(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_INVENTORY(inventoryId)),
+ invoicesForBooking: (bookingId: string) =>
+ apiClient.get(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_BOOKING(bookingId)),
+ generateInvoice: (inventoryId: string, confirmZero = false) =>
+ apiClient.post(URL_CONSTANTS.WAREHOUSE_INVOICES.GENERATE(inventoryId), { confirmZero }),
+ cancelInvoice: (id: string) =>
+ apiClient.patch(URL_CONSTANTS.WAREHOUSE_INVOICES.CANCEL(id)),
+ payInvoice: (id: string, payload: PayInvoicePayload) =>
+ apiClient.post(URL_CONSTANTS.WAREHOUSE_INVOICES.PAY(id), payload),
+ gateClearance: (inventoryId: string) =>
+ apiClient.post(URL_CONSTANTS.WAREHOUSE_INVOICES.GATE_CLEARANCE(inventoryId), {}),
};
diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
index ad4d433be..15b67c3da 100644
--- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts
+++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
@@ -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 {