From f0d66ce8f97c6323f32633cc68dda39806f3f1f9 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 20 Aug 2026 11:36:50 +0000 Subject: [PATCH] feat(invoices): show and search what an invoice was raised against MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `source` named the subsystem and `sourceId` was a raw UUID, so the list could not say which record an invoice belonged to, and search matched only the invoice number and that UUID — nobody types a UUID. Every source except a shipping-line credit hangs off a booking, directly or through the warehouse/first-mile/last-mile record, so the list read now resolves each row to a booking reference, GRN or shipping line and sends it as `sourceRef`. Search spans the same ground plus the customer name, with the raw sourceId still matchable so a pasted UUID keeps working. --- .../src/modules/billing/billing.service.ts | 141 +++++++++- .../src/pages/invoices/InvoicesPage.tsx | 264 +++++++++--------- .../backoffice/src/types/invoice.ts | 18 ++ 3 files changed, 291 insertions(+), 132 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index d64361bf8..ac1c2ef24 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -46,6 +46,29 @@ export interface PayInvoiceOptions { failureUrl?: string; } +/** + * What an invoice's `sourceId` actually points at, resolved for display. + * + * `source` alone ("warehouse", "booking", …) says which subsystem raised the + * invoice but nothing about *which* record, and `sourceId` is a raw UUID. Every + * source except a shipping-line credit hangs off a booking — directly + * (booking/clearance) or through the warehouse/first-mile/last-mile record — + * so the booking reference is the one label that identifies almost any row. + */ +export interface InvoiceSourceRef { + /** Booking behind the invoice, when there is one. Null for shipping-line credits. */ + bookingId: string | null; + bookingReference: string | null; + tradeDirection: string | null; + /** Warehouse-sourced rows: the goods-received note the fees were raised against. */ + grnNumber: string | null; + /** Shipping-line credit rows: `sourceId` is the line's own id, not a record's. */ + shippingLineName: string | null; +} + +/** Row shape of the backoffice invoice list: the entity plus its resolved source. */ +export type InvoiceListRow = Invoice & { sourceRef: InvoiceSourceRef | null }; + /** Booking context attached to a finance offline-USD invoice row. */ export interface OfflineUsdBookingInfo { id: string; @@ -236,8 +259,32 @@ export class BillingService { qb.andWhere("invoice.status = :status", { status: filter.status }); } if (filter.search) { + // Searches what the row actually shows: its number, who it bills, and + // the source record behind it (booking reference, GRN, shipping line). + // The raw `sourceId` stays matchable so a pasted UUID still resolves. + // Requires the `company` alias — every caller of this joins it. qb.andWhere( - "(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)", + `(invoice.invoiceNumber ILIKE :search + OR invoice.sourceId ILIKE :search + OR company.name ILIKE :search + OR EXISTS ( + SELECT 1 FROM freight.bookings b + LEFT JOIN freight.warehouse_inventory wi ON wi.booking_id = b.id + LEFT JOIN freight.first_mile fm ON fm.booking_id = b.id + LEFT JOIN freight.last_mile lm ON lm.booking_id = b.id + WHERE b.reference ILIKE :search + AND (b.id::text = invoice.source_id + OR wi.id::text = invoice.source_id + OR fm.id::text = invoice.source_id + OR lm.id::text = invoice.source_id)) + OR EXISTS ( + SELECT 1 FROM freight.warehouse_inventory wi2 + WHERE wi2.id::text = invoice.source_id + AND wi2.grn_number ILIKE :search) + OR EXISTS ( + SELECT 1 FROM freight.shipping_line_companies slc + WHERE slc.id::text = invoice.source_id + AND slc.name ILIKE :search))`, { search: `%${filter.search}%` }, ); } @@ -261,7 +308,7 @@ export class BillingService { /** Per-user trade-direction scope, applied via the source booking. */ tradeDirections?: string[]; } = {}, - ): Promise<{ items: Invoice[]; total: number }> { + ): Promise<{ items: InvoiceListRow[]; total: number }> { const page = filter.page && filter.page > 0 ? filter.page : 1; const pageSize = filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20; @@ -277,7 +324,92 @@ export class BillingService { this.applyInvoiceFilters(qb, filter); const [items, total] = await qb.getManyAndCount(); - return { items: await this.attachShippingLineCompanies(items), total }; + const withLines = await this.attachShippingLineCompanies(items); + return { items: await this.attachSourceRefs(withLines), total }; + } + + /** + * Resolve each row's `sourceId` to the record it points at, in one query for + * the whole page. `sourceId` is a bare varchar pointer with no FK and no + * relation to eager-load, and which table it addresses depends on `source` — + * so this walks every candidate table at once and lands on the booking + * through whichever one matched. + * + * `sourceId` is not always a UUID (EIMS self-test rows carry a slug), hence + * the shape guard before every cast — an unguarded `::uuid` throws on those. + */ + private async attachSourceRefs( + invoices: T[], + ): Promise<(T & { sourceRef: InvoiceSourceRef | null })[]> { + const sourceIds = [ + ...new Set(invoices.map((i) => i.sourceId).filter(Boolean)), + ]; + if (!sourceIds.length) { + return invoices.map((invoice) => ({ ...invoice, sourceRef: null })); + } + + const rows: { + sourceId: string; + bookingId: string | null; + bookingReference: string | null; + tradeDirection: string | null; + grnNumber: string | null; + shippingLineName: string | null; + }[] = await this.dataSource.query( + `SELECT s.source_id AS "sourceId", + b.id::text AS "bookingId", + b.reference AS "bookingReference", + b.trade_direction AS "tradeDirection", + wi.grn_number AS "grnNumber", + slc.name AS "shippingLineName" + FROM unnest($1::text[]) AS s(source_id) + LEFT JOIN freight.warehouse_inventory wi + ON wi.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$' + THEN s.source_id::uuid END) + AND wi.deleted_at IS NULL + LEFT JOIN freight.first_mile fm + ON fm.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$' + THEN s.source_id::uuid END) + AND fm.deleted_at IS NULL + LEFT JOIN freight.last_mile lm + ON lm.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$' + THEN s.source_id::uuid END) + AND lm.deleted_at IS NULL + LEFT JOIN freight.bookings b + ON b.id = COALESCE(wi.booking_id, fm.booking_id, lm.booking_id, + CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$' + THEN s.source_id::uuid END) + AND b.deleted_at IS NULL + LEFT JOIN freight.shipping_line_companies slc + ON slc.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$' + THEN s.source_id::uuid END) + AND slc.deleted_at IS NULL`, + [sourceIds], + ); + + const bySourceId = new Map(rows.map((r) => [r.sourceId, r])); + return invoices.map((invoice) => { + const row = bySourceId.get(invoice.sourceId); + const sourceRef: InvoiceSourceRef | null = row + ? { + bookingId: row.bookingId, + bookingReference: row.bookingReference, + tradeDirection: row.tradeDirection, + grnNumber: row.grnNumber, + shippingLineName: row.shippingLineName, + } + : null; + // Nothing resolved (an EIMS self-test row, a deleted record) → null, + // and the UI falls back to the plain source label. + const resolved = + sourceRef && + (sourceRef.bookingId || + sourceRef.grnNumber || + sourceRef.shippingLineName) + ? sourceRef + : null; + return { ...invoice, sourceRef: resolved }; + }); } /** @@ -336,6 +468,9 @@ export class BillingService { const qb = this.dataSource .getRepository(Invoice) .createQueryBuilder("invoice") + // Joined, not selected: `applyInvoiceFilters` searches the customer name, + // so the alias has to exist even though the summary only sums money. + .leftJoin("invoice.company", "company") .select("invoice.currency", "currency") .addSelect("SUM(invoice.paidAmount)", "collected") .groupBy("invoice.currency"); diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx index a8211f73f..68cb7514d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -1,6 +1,7 @@ import type { Freight } from "@edr/types"; import { ActionIcon, + Badge, Box, Card, Group, @@ -11,35 +12,55 @@ import { } from "@mantine/core"; import { useDebouncedValue } from "@mantine/hooks"; import { useQuery } from "@tanstack/react-query"; -import { - Banknote, - CircleDollarSign, - Landmark, - RefreshCw, - Search, - X, -} from "lucide-react"; +import { Banknote, CircleDollarSign, Landmark, RefreshCw, Search, X } from "lucide-react"; import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { - InvoiceStatusBadge, - formatDate, - formatMoney, - humanize, -} from "@/components/customers"; +import { InvoiceStatusBadge, formatDate, formatMoney, humanize } from "@/components/customers"; import { KpiStrip } from "@/components/page"; import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions"; import { ExportButton } from "@/components/export/ExportButton"; import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings"; import { api } from "@/services/api"; import type { Invoice } from "@/types/invoice"; -import { - DataTable, - DataTableFooter, - usePagination, - type ColumnDef, -} from "@edr/ui-common"; +import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common"; + +/** + * Which record raised the invoice, not just which subsystem. The source label + * stays (it says how the charge arose); under it sits the reference a human + * actually recognises — booking, GRN, or the shipping line billed. Falls back + * to the bare label when the server resolved nothing. + */ +function InvoiceSourceCell({ invoice }: { invoice: Invoice }) { + const ref = invoice.sourceRef; + const detail = ref?.bookingReference ?? ref?.shippingLineName ?? null; + + return ( + + + + {humanize(invoice.source)} + + {ref?.tradeDirection ? ( + + {ref.tradeDirection} + + ) : null} + + {detail ? ( + + {detail} + + ) : null} + {/* GRN only when it adds something the booking reference doesn't. */} + {ref?.grnNumber ? ( + + {ref.grnNumber} + + ) : null} + + ); +} /** Invoices tab body of `FinanceHubPage` — page chrome lives in the parent. */ export default function InvoicesPanel() { @@ -47,9 +68,7 @@ export default function InvoicesPanel() { const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); const [debouncedQuery] = useDebouncedValue(query, 300); - const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>( - "", - ); + const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(""); const filter = useMemo( () => ({ @@ -72,10 +91,7 @@ export default function InvoicesPanel() { // Shipping-line credit invoices carry maker–checker actions (mark paid / // cancel). One batched lookup fetches the visible rows' pending requests. const creditInvoiceIds = useMemo( - () => - rows - .filter((inv) => inv.source === "shipping_line_credit") - .map((inv) => inv.id), + () => rows.filter((inv) => inv.source === "shipping_line_credit").map((inv) => inv.id), [rows], ); const { data: pendingActions } = useQuery( @@ -120,20 +136,15 @@ export default function InvoicesPanel() { header: "Billed to", cell: ({ row }) => ( - {row.original.company?.name ?? - row.original.shippingLineCompany?.name ?? - "—"} + {row.original.company?.name ?? row.original.shippingLineCompany?.name ?? "—"} ), }, { id: "source", header: "Source", - cell: ({ row }) => ( - - {humanize(row.original.source)} - - ), + size: 220, + cell: ({ row }) => , }, { id: "status", @@ -172,7 +183,6 @@ export default function InvoicesPanel() { }, { id: "actions", - header: "Actions", cell: ({ row }) => { const inv = row.original; // Only shipping-line credit invoices have manual maker–checker @@ -224,100 +234,96 @@ export default function InvoicesPanel() { /> - - - - } - value={query} - onChange={(e) => setQuery(e.target.value)} - rightSection={ - query ? ( - setQuery("")} - > - - - ) : null - } - style={{ flex: 1, minWidth: "240px" }} - radius="lg" - /> - - { - setStatusFilter( - v === "all" ? "" : (v as Freight.InvoiceStatus), - ); - setPagination((prev) => ({ ...prev, pageIndex: 0 })); - }} - data={[ - { label: "All", value: "all" }, - { label: "Pending", value: "PENDING" }, - { label: "Payment processing", value: "PAYMENT_PROCESSING" }, - { label: "Paid", value: "PAID" }, - { label: "Overdue", value: "OVERDUE" }, - ]} - /> - void refetch()} - > - - - - - - - - navigate(`/dashboard/invoices/${row.id}`)} - emptyMessage={ - debouncedQuery - ? "No invoices match your search." - : "No invoices yet." - } - error={ - isError - ? { - message: "Failed to load invoices.", - onRetry: () => void refetch(), - } - : undefined - } - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount, - totalCount: total, - }} - tableOptions={{ - state: { pagination }, - onPaginationChange: setPagination, - manualPagination: true, - pageCount, - }} - containerClassName="border-0 shadow-none bg-transparent" - footer={DataTableFooter} - /> + + + + } + value={query} + onChange={(e) => setQuery(e.target.value)} + rightSection={ + query ? ( + setQuery("")} + > + + + ) : null + } + style={{ flex: 1, minWidth: "240px" }} + radius="lg" + /> + + { + setStatusFilter(v === "all" ? "" : (v as Freight.InvoiceStatus)); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }} + data={[ + { label: "All", value: "all" }, + { label: "Pending", value: "PENDING" }, + { label: "Payment processing", value: "PAYMENT_PROCESSING" }, + { label: "Paid", value: "PAID" }, + { label: "Overdue", value: "OVERDUE" }, + ]} + /> + void refetch()} + > + + + - - + + + + navigate(`/dashboard/invoices/${row.id}`)} + emptyMessage={ + debouncedQuery ? "No invoices match your search." : "No invoices yet." + } + error={ + isError + ? { + message: "Failed to load invoices.", + onRetry: () => void refetch(), + } + : undefined + } + pagination={{ + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount, + totalCount: total, + }} + tableOptions={{ + state: { pagination }, + onPaginationChange: setPagination, + manualPagination: true, + pageCount, + }} + containerClassName="border-0 shadow-none bg-transparent" + footer={DataTableFooter} + /> + + + ); diff --git a/apps/edr-freight-web/backoffice/src/types/invoice.ts b/apps/edr-freight-web/backoffice/src/types/invoice.ts index a266a6257..3f2f661e1 100644 --- a/apps/edr-freight-web/backoffice/src/types/invoice.ts +++ b/apps/edr-freight-web/backoffice/src/types/invoice.ts @@ -1,9 +1,27 @@ import type { Freight } from "@edr/types"; +/** + * What an invoice's `sourceId` points at, resolved server-side for display. + * `source` names the subsystem, `sourceId` is a raw UUID — this is the part a + * human recognises. Null when nothing resolved (EIMS self-test rows, records + * since deleted). See `InvoiceSourceRef` in the API's billing service. + */ +export interface InvoiceSourceRef { + bookingId: string | null; + bookingReference: string | null; + tradeDirection: string | null; + /** Warehouse-sourced rows: the GRN the fees were raised against. */ + grnNumber: string | null; + /** Shipping-line credit rows: the line billed, not a single record. */ + shippingLineName: string | null; +} + /** Mirrors backend `Invoice` (the shared `Freight.IInvoice` omits a couple of raw entity columns). */ export interface Invoice extends Freight.IInvoice { subtotalAmount: number; taxAmount: number; + /** Present on list reads (`findAllPaginated`), absent on a single-invoice fetch. */ + sourceRef?: InvoiceSourceRef | null; } /** Query parameters for the invoice list. */