fix: invoice filter

This commit is contained in:
Nathnael
2026-08-27 12:40:03 +00:00
parent 63bd9197c5
commit 7d9d1fb518
3 changed files with 316 additions and 144 deletions

View File

@@ -1,4 +1,4 @@
import type { Freight } from "@edr/types";
import { Freight } from "@edr/types";
import {
ActionIcon,
Badge,
@@ -7,15 +7,19 @@ import {
Card,
Group,
Modal,
SegmentedControl,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CheckCircle2, ExternalLink, RefreshCw, Search, X } from "lucide-react";
import {
CheckCircle2,
CircleDollarSign,
ExternalLink,
Receipt,
RefreshCw,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
@@ -25,18 +29,125 @@ import {
formatMoney,
humanize,
} from "@/components/customers";
import {
FilterBar,
dateRangeParams,
isoToLocalDateStr,
useFilters,
type FilterDef,
} from "@/components/filters";
import { KpiStrip } from "@/components/page";
import { ExportButton } from "@/components/export/ExportButton";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { useAuth } from "@/auth/useAuth";
import { useManualPaymentSettingsQuery } from "@/hooks/useManualPaymentSettings";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type { OfflineUsdInvoice } from "@/types/invoice";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
PAYMENT_METHOD_OPTIONS,
type InvoiceListFilter,
type OfflineUsdInvoice,
} from "@/types/invoice";
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((value) => ({
value,
label: humanize(value),
}));
const SOURCE_OPTIONS = Object.values(Freight.InvoiceSource).map((value) => ({
value,
label: humanize(value),
}));
/**
* Mirrors `OPEN_STATUSES` in the API's billing service — the implicit "still
* needs settling" cut this worklist applies when no status pill is set. Only
* the export needs it spelled out (see `exportParams`); the list gets it from
* the server.
*/
const OPEN_STATUSES = [
Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.PaymentProcessing,
Freight.InvoiceStatus.PartiallyPaid,
Freight.InvoiceStatus.Overdue,
];
/**
* The same filter vocabulary the invoices list uses, minus `currency` — this
* panel is mounted once per currency and pins it from the prop, so offering it
* as a pill could only contradict the tab you are on.
*/
const MANUAL_PAYMENT_FILTER_DEFS: FilterDef[] = [
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
{
// Computed from the balance and due date rather than read off `status` —
// nothing sweeps PENDING rows into OVERDUE, so the status under-reports.
key: "settlement",
label: "Settlement",
type: "enum",
multiple: false,
options: [
{ value: "outstanding", label: "Outstanding" },
{ value: "overdue", label: "Overdue" },
],
toParams: (v) =>
v.v[0] === "overdue" ? { overdue: "true" } : { hasBalance: "true" },
},
{
key: "issued",
label: "Issued",
type: "date",
operators: ["between", "before", "after"],
toParams: dateRangeParams("issuedFrom", "issuedTo"),
},
{
key: "sources",
label: "Source",
type: "enum",
secondary: true,
options: SOURCE_OPTIONS,
},
{
key: "paymentMethods",
label: "Payment method",
type: "enum",
secondary: true,
options: PAYMENT_METHOD_OPTIONS,
},
{
key: "due",
label: "Due",
type: "date",
secondary: true,
operators: ["between", "before", "after"],
toParams: dateRangeParams("dueFrom", "dueTo"),
},
{
key: "amount",
label: "Amount",
type: "number",
secondary: true,
operators: ["between", "is"],
toParams: (v) =>
v.op === "between"
? { minAmount: v.v[0], maxAmount: v.v[1] }
: { minAmount: v.v[0], maxAmount: v.v[0] },
},
];
const SORT_OPTIONS = [
{ value: "issuedAt:DESC", label: "Newest issued" },
{ value: "issuedAt:ASC", label: "Oldest issued" },
{ value: "dueAt:ASC", label: "Due soonest" },
{ value: "totalAmount:DESC", label: "Largest amount" },
{ value: "balanceAmount:DESC", label: "Largest balance" },
{ value: "invoiceNumber:ASC", label: "Invoice no. (AZ)" },
];
/** Date params the export's `daterange` coercion expects as calendar days. */
const EXPORT_DAY_KEYS = ["issuedFrom", "issuedTo", "dueFrom", "dueTo"];
/**
* The customer's pay window, counted down live. Finance must confirm the bank
@@ -157,21 +268,19 @@ export default function UsdPaymentsPanel({
currency: "USD" | "ETB";
}) {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
"",
);
// Namespaced: the ETB and USD tabs share this panel and live on the same URL
// as the Invoices tab, whose filter bar owns the bare `statuses`/`sort` keys.
const controls = useFilters(MANUAL_PAYMENT_FILTER_DEFS, {
defaultSort: "issuedAt:DESC",
pageSize: 10,
ns: "mp",
});
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
const [slip, setSlip] = useState<File | null>(null);
const [reference, setReference] = useState("");
const { user } = useAuth();
const canConfirm = hasPermission(
user,
FREIGHT_PERMS.invoices.confirmOffline,
);
const canConfirm = hasPermission(user, FREIGHT_PERMS.invoices.confirmOffline);
// Manual settlement is switched on per currency in Configuration → Manual
// payments. FinanceHubPage hides the tab for a disabled currency; this is
@@ -184,20 +293,8 @@ export default function UsdPaymentsPanel({
: true;
const filter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
status: statusFilter || undefined,
currency,
}),
[
pagination.pageIndex,
pagination.pageSize,
debouncedQuery,
statusFilter,
currency,
],
() => ({ ...controls.params, currency }) as unknown as InvoiceListFilter,
[controls.params, currency],
);
const { data, isLoading, isError, refetch, isFetching } = useQuery({
@@ -209,7 +306,24 @@ export default function UsdPaymentsPanel({
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const outstanding = data?.outstanding?.[currency] ?? 0;
/**
* The export's `daterange` filters are coerced from calendar days while the
* list takes ISO instants, so each bound is handed over as the local day it
* falls on. The worklist's implicit "still open" cut is not a URL param
* either — spelled out here so an exported file covers the rows the screen
* shows rather than every invoice ever raised in this currency.
*/
const exportParams = useMemo(() => {
const out: Record<string, unknown> = { ...controls.params, currency };
for (const key of EXPORT_DAY_KEYS) {
if (typeof out[key] === "string")
out[key] = isoToLocalDateStr(out[key] as string);
}
if (!out.statuses) out.statuses = OPEN_STATUSES.join(",");
return out;
}, [controls.params, currency]);
const closeConfirm = () => {
setConfirming(null);
@@ -340,7 +454,9 @@ export default function UsdPaymentsPanel({
header: "Pay window",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<PayWindowCell deadline={row.original.booking?.paymentDeadline ?? null} />
<PayWindowCell
deadline={row.original.booking?.paymentDeadline ?? null}
/>
),
},
{
@@ -358,47 +474,40 @@ export default function UsdPaymentsPanel({
);
return (
<>
<Stack gap="md">
<KpiStrip
loading={isLoading}
items={[
{
label: `Outstanding in ${currency}`,
hint: "all matching",
value: formatMoney(outstanding, currency),
icon: CircleDollarSign,
color: "edr-green",
},
{
label: "Invoices listed",
value: total,
icon: Receipt,
color: "blue",
},
]}
/>
<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"
/>
<SegmentedControl
<FilterBar
defs={MANUAL_PAYMENT_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search invoice, customer, booking ref, PNR, transaction ref, GRN or shipping line…"
sortOptions={SORT_OPTIONS}
viewId={`manual-payments-${currency.toLowerCase()}`}
>
<ExportButton
datasetKey="invoices"
params={exportParams}
size="sm"
radius="md"
value={statusFilter || "open"}
onChange={(v) => {
setStatusFilter(
v === "open" ? "" : (v as Freight.InvoiceStatus),
);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "Awaiting payment", value: "open" },
{ label: "Paid", value: "PAID" },
{ label: "Overdue", value: "OVERDUE" },
]}
/>
<ActionIcon
variant="default"
@@ -410,7 +519,7 @@ export default function UsdPaymentsPanel({
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
</FilterBar>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
@@ -423,8 +532,8 @@ export default function UsdPaymentsPanel({
emptyMessage={
!currencyEnabled
? `Manual payment is switched off for ${currency} invoices. Enable it in Configuration → Manual payments.`
: debouncedQuery
? "No invoices match your search."
: controls.activeCount > 0
? "No invoices match these filters."
: `No ${currency} invoices awaiting manual payment confirmation.`
}
error={
@@ -435,18 +544,7 @@ export default function UsdPaymentsPanel({
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
{...controls.tableProps(total)}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
@@ -458,9 +556,7 @@ export default function UsdPaymentsPanel({
<Modal
opened={confirming !== null}
onClose={closeConfirm}
title={
<Text fw={700}>Confirm manual payment</Text>
}
title={<Text fw={700}>Confirm manual payment</Text>}
radius="md"
size="md"
>
@@ -510,6 +606,6 @@ export default function UsdPaymentsPanel({
</Stack>
)}
</Modal>
</>
</Stack>
);
}

View File

@@ -118,6 +118,11 @@ export interface OfflineUsdInvoice extends Invoice {
export interface PaginatedOfflineUsdInvoices {
items: OfflineUsdInvoice[];
total: number;
/**
* Outstanding `balanceAmount` across the whole filtered set (not the visible
* page), keyed by normalised currency — feeds the worklist's KPI strip.
*/
outstanding: Record<string, number>;
}
/** Total collected (`paidAmount`) across every filtered invoice, keyed by currency. */