feat(invoices): show and search what an invoice was raised against

`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.
This commit is contained in:
Nathnael
2026-08-20 11:36:50 +00:00
parent 05efdd5d54
commit f0d66ce8f9
3 changed files with 291 additions and 132 deletions

View File

@@ -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<T extends Invoice>(
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");

View File

@@ -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 (
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="sm" c="edr-text" lh={1.2}>
{humanize(invoice.source)}
</Text>
{ref?.tradeDirection ? (
<Badge size="xs" variant="light" color="gray">
{ref.tradeDirection}
</Badge>
) : null}
</Group>
{detail ? (
<Text size="xs" c="dimmed" ff="monospace" lh={1.2} truncate>
{detail}
</Text>
) : null}
{/* GRN only when it adds something the booking reference doesn't. */}
{ref?.grnNumber ? (
<Text size="xs" c="dimmed" lh={1.2} truncate>
{ref.grnNumber}
</Text>
) : null}
</Stack>
);
}
/** 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 makerchecker 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 }) => (
<Text size="sm" c="edr-text" truncate maw={200}>
{row.original.company?.name ??
row.original.shippingLineCompany?.name ??
"—"}
{row.original.company?.name ?? row.original.shippingLineCompany?.name ?? "—"}
</Text>
),
},
{
id: "source",
header: "Source",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{humanize(row.original.source)}
</Text>
),
size: 220,
cell: ({ row }) => <InvoiceSourceCell invoice={row.original} />,
},
{
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 makerchecker
@@ -224,100 +234,96 @@ export default function InvoicesPanel() {
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search by invoice number…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
) : null
}
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<ExportButton datasetKey="invoices" params={filter} size="sm" />
<SegmentedControl
size="sm"
radius="md"
value={statusFilter || "all"}
onChange={(v) => {
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" },
]}
/>
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Refresh"
loading={isFetching}
onClick={() => void refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={920}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => 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}
/>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search invoice, customer, booking ref, GRN or shipping line…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
) : null
}
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<ExportButton datasetKey="invoices" params={filter} size="sm" />
<SegmentedControl
size="sm"
radius="md"
value={statusFilter || "all"}
onChange={(v) => {
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" },
]}
/>
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Refresh"
loading={isFetching}
onClick={() => void refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
</Box>
</Box>
</Stack>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={920}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => 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}
/>
</Box>
</Box>
</Stack>
</Card>
</Stack>
);

View File

@@ -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. */