From be12a9d496ac5275aacd808a8c4b3be8b92abdd7 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 18 Jun 2026 00:12:59 +0000 Subject: [PATCH] fee invoice frontend --- .../modules/warehouses/warehouses.module.ts | 3 + apps/edr-freight-web/backoffice/src/App.tsx | 7 + .../components/warehouses/FeePreviewModal.tsx | 104 +++++++- .../backoffice/src/constants/URLS.ts | 11 + .../backoffice/src/hooks/useWarehouses.ts | 63 +++++ .../warehouses/WarehouseInvoicesPage.tsx | 238 ++++++++++++++++++ .../src/services/warehouse.service.ts | 23 ++ .../backoffice/src/types/warehouse.ts | 79 ++++++ 8 files changed, 520 insertions(+), 8 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx 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 + + + ) : ( + + )} + + + - 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} + /> +