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

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