mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
fix: invoice filter
This commit is contained in:
@@ -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. (A–Z)" },
|
||||
];
|
||||
|
||||
/** 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user