chore: reporting and filtering

This commit is contained in:
Nathnael
2026-08-21 09:25:21 +00:00
parent f55645fea9
commit 25d13baa6f
23 changed files with 941 additions and 902 deletions

View File

@@ -4,7 +4,6 @@ import {
Box,
Card,
Group,
SegmentedControl,
Stack,
Text,
Tooltip,
@@ -22,7 +21,7 @@ import {
ShieldOff,
Users,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import {
@@ -31,49 +30,22 @@ import {
ManualRegistrationBadge,
ProfileChips,
formatDate,
humanize,
} from "@/components/customers";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type { Company, CompanyStatus } from "@/types/customer";
import type { Company, CompanyListFilter } from "@/types/customer";
import { isOnboardingDraft } from "@/types/customer";
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
import { FilterBar, useFilters, type FilterDef } from "@/components/filters";
import {
FilterBar,
dateRangeParams,
isoToLocalDateStr,
useFilters,
type FilterDef,
} from "@/components/filters";
import { ExportButton } from "@/components/export/ExportButton";
/**
* The list's segmented views. "Pending approval" means submitted-and-awaiting-
* review, so it excludes drafts — a company row exists from the onboarding
* wizard's first click and would otherwise pad the review queue. Those drafts
* get their own view instead of disappearing, so staff can still chase them.
*/
type CustomerView =
| "all"
| "pending"
| "pendingChanges"
| "onboarding"
| "active";
/**
* "Pending changes" is deliberately not folded into "Pending approval". A
* customer who edits their profile after being approved stays `status = active`,
* so the pending filter can never match them — their resubmission would only
* ever be visible by opening their detail page. This view is that queue.
*/
const VIEW_FILTERS: Record<
CustomerView,
{
status?: CompanyStatus;
onboardingCompleted?: boolean;
hasPendingChangeRequest?: boolean;
}
> = {
all: {},
pending: { status: "pending", onboardingCompleted: true },
pendingChanges: { hasPendingChangeRequest: true },
onboarding: { onboardingCompleted: false },
active: { status: "active" },
};
const SORT_OPTIONS = [
// Queue ordering: awaiting first approval → pending profile changes → the
// rest, newest first within each group. The default, so whatever marketing
@@ -85,29 +57,110 @@ const SORT_OPTIONS = [
{ value: "name:DESC", label: "Name (ZA)" },
] as const;
/** No filter pills — search/sort/page are the only real filter dimensions;
* `view` below is a tab (mutually exclusive, navigational), not a filter. */
const NO_FILTER_DEFS: FilterDef[] = [];
/**
* Every state a customer can be in, as one single-select list.
*
* Three of these are not `companies.status` values at all, which is why each
* option maps its own params:
* - **Pending approval** is submitted-and-awaiting-review, so it excludes
* drafts — a company row exists from the onboarding wizard's first click and
* would otherwise pad the review queue.
* - **Onboarding** is that draft: still in the portal wizard, never submitted.
* - **Pending changes** is an already-approved (`active`) customer who edited
* their profile. `status` can never match them, so without this option their
* resubmission is only visible by opening their detail page.
*/
const STATUS_OPTIONS: {
value: string;
label: string;
params: Record<string, string>;
}[] = [
{ value: "pending", label: "Pending approval", params: { status: "pending", onboardingCompleted: "true" } },
{ value: "pendingChanges", label: "Pending changes", params: { hasPendingChangeRequest: "true" } },
{ value: "onboarding", label: "Onboarding", params: { onboardingCompleted: "false" } },
{ value: "active", label: "Active", params: { status: "active" } },
{ value: "suspended", label: "Suspended", params: { status: "suspended" } },
{ value: "blacklisted", label: "Blacklisted", params: { status: "blacklisted" } },
];
/**
* Filter pills. The review queues that used to sit beside them as segmented
* tabs are folded into the Status pill above — three of the five were never a
* plain `status` value, so as a separate tab strip they could contradict the
* status filter next to them. One list, mutually exclusive, no contradiction.
*/
const CUSTOMER_FILTER_DEFS: FilterDef[] = [
{
key: "status",
label: "Status",
type: "enum",
multiple: false,
options: STATUS_OPTIONS.map(({ value, label }) => ({ value, label })),
toParams: (v) =>
STATUS_OPTIONS.find((o) => o.value === v.v[0])?.params ?? {},
},
{
key: "type",
label: "Type",
type: "enum",
multiple: false,
options: (
["customer", "freight_forwarder", "dj_freight_forwarder", "transporter"] as const
).map((value) => ({ value, label: humanize(value) })),
},
{
key: "kind",
label: "Sector",
type: "enum",
multiple: false,
options: [
{ value: "commercial", label: "Commercial" },
{ value: "government", label: "Government" },
],
},
{
key: "nationality",
label: "Nationality",
type: "enum",
multiple: false,
options: [
{ value: "ethiopian", label: "Ethiopian" },
{ value: "foreign", label: "Foreign" },
],
},
{
key: "created",
label: "Registered",
type: "date",
secondary: true,
operators: ["between", "before", "after"],
toParams: dateRangeParams("createdFrom", "createdTo"),
},
];
export default function CustomersPage() {
const navigate = useNavigate();
const [view, setView] = useState<CustomerView>("all");
const controls = useFilters(NO_FILTER_DEFS, { defaultSort: "review:DESC", pageSize: 10 });
const controls = useFilters(CUSTOMER_FILTER_DEFS, {
defaultSort: "review:DESC",
pageSize: 10,
});
const filter = useMemo(() => {
const [sortBy, sortOrder] = controls.sort.split(":") as [
"review" | "name" | "createdAt" | "updatedAt",
"ASC" | "DESC",
];
return {
page: controls.page,
pageSize: controls.pageSize,
search: String(controls.params.search ?? ""),
sortBy,
sortOrder,
...VIEW_FILTERS[view],
};
}, [controls.page, controls.pageSize, controls.params.search, controls.sort, view]);
// `controls.params` is the whole query: page/pageSize/search, the split
// sortBy/sortOrder, and every pill's mapped params.
const filter = controls.params as unknown as CompanyListFilter;
/**
* The export's `daterange` filters are coerced from calendar days while the
* list takes ISO instants — hand the dialog the local day each bound falls on
* so the file covers the same range the screen shows.
*/
const exportParams = useMemo(() => {
const out: Record<string, unknown> = { ...controls.params };
for (const key of ["createdFrom", "createdTo"]) {
if (typeof out[key] === "string") out[key] = isoToLocalDateStr(out[key] as string);
}
return out;
}, [controls.params]);
const { data: stats } = useQuery(
api.customers.stats.queryOptions({ input: {} }),
@@ -293,33 +346,13 @@ export default function CustomersPage() {
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FilterBar
defs={NO_FILTER_DEFS}
defs={CUSTOMER_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search by company, TIN, email or profile reference…"
sortOptions={SORT_OPTIONS.map((o) => ({ ...o }))}
viewId="customers"
>
<SegmentedControl
size="sm"
radius="md"
value={view}
onChange={(v) => {
// `view` lives outside useFilters (it's a tab, not a
// filter pill), so switching it needs its own page reset —
// the same "stranded on page 5" hazard useFilters guards
// against for its own filters.
setView(v as CustomerView);
controls.setPage(1);
}}
data={[
{ label: "All", value: "all" },
{ label: "Pending approval", value: "pending" },
{ label: "Pending changes", value: "pendingChanges" },
{ label: "Onboarding", value: "onboarding" },
{ label: "Active", value: "active" },
]}
/>
<ExportButton datasetKey="customers" params={controls.params} />
<ExportButton datasetKey="customers" params={exportParams} />
</FilterBar>
</Box>
@@ -331,8 +364,8 @@ export default function CustomersPage() {
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/customers/${row.id}`)}
emptyMessage={
controls.searchText
? "No companies match your search."
controls.activeCount > 0
? "No companies match these filters."
: "No companies yet."
}
error={

View File

@@ -1,29 +1,127 @@
import type { Freight } from "@edr/types";
import {
ActionIcon,
Badge,
Box,
Card,
Group,
SegmentedControl,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { Freight } from "@edr/types";
import { ActionIcon, Badge, Box, Card, Group, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Banknote, CircleDollarSign, Landmark, RefreshCw, Search, X } from "lucide-react";
import { useMemo, useState } from "react";
import { Banknote, CircleDollarSign, Landmark, RefreshCw } from "lucide-react";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { InvoiceStatusBadge, formatDate, formatMoney, humanize } from "@/components/customers";
import {
FilterBar,
dateRangeParams,
isoToLocalDateStr,
useFilters,
type FilterDef,
} from "@/components/filters";
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 type { Invoice, InvoiceListFilter } 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 `EimsInvoiceStatus` in the API — Finance's "what still needs filing" cut. */
const EIMS_STATUS_OPTIONS = [
"NOT_SUBMITTED",
"SUBMITTING",
"REGISTERED",
"FAILED",
"UNKNOWN",
"CANCELLED",
].map((value) => ({ value, label: humanize(value) }));
/**
* Every dimension the list narrows by. Keys are the URL keys; `toParams` maps
* them onto the API's `FilterInvoiceDto`. Secondary defs sit behind "More
* filters" until they hold a value, then pin themselves as a pill.
*/
const INVOICE_FILTER_DEFS: FilterDef[] = [
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
{ key: "sources", label: "Source", type: "enum", options: SOURCE_OPTIONS },
{
key: "currency",
label: "Currency",
type: "enum",
multiple: false,
options: [
{ value: "ETB", label: "ETB" },
{ value: "USD", label: "USD" },
],
},
{
// One pill for the two settlement cuts Finance actually chases. Both are
// 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: "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"],
// Amounts are compared in each invoice's OWN currency — pair this with the
// currency pill when the mix matters.
toParams: (v) =>
v.op === "between"
? { minAmount: v.v[0], maxAmount: v.v[1] }
: { minAmount: v.v[0], maxAmount: v.v[0] },
},
{
key: "eimsStatuses",
label: "EIMS",
type: "enum",
secondary: true,
options: EIMS_STATUS_OPTIONS,
},
];
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"];
/**
* Which record raised the invoice, not just which subsystem. The source label
@@ -65,20 +163,12 @@ function InvoiceSourceCell({ invoice }: { invoice: Invoice }) {
/** Invoices tab body of `FinanceHubPage` — page chrome lives in the parent. */
export default function InvoicesPanel() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>("");
const controls = useFilters(INVOICE_FILTER_DEFS, {
defaultSort: "issuedAt:DESC",
pageSize: 10,
});
const filter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
status: statusFilter || undefined,
}),
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
);
const filter = controls.params as unknown as InvoiceListFilter;
const { data, isLoading, isError, refetch, isFetching } = useQuery(
api.invoices.list.queryOptions({ input: { filter } }),
@@ -86,7 +176,6 @@ export default function InvoicesPanel() {
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
// Shipping-line credit invoices carry makerchecker actions (mark paid /
// cancel). One batched lookup fetches the visible rows' pending requests.
@@ -106,14 +195,28 @@ export default function InvoicesPanel() {
);
// Summary card: total collected (paidAmount) across every invoice matching
// the current search/status filters, not just the visible page.
// the current filters, not just the visible page. Same params minus
// pagination, so the card can never total a different set than the table.
const summaryFilter = useMemo(() => {
const { page: _page, pageSize: _pageSize, ...rest } = filter;
return rest;
}, [filter]);
const { data: summary, isLoading: summaryLoading } = useQuery(
api.invoices.collectedSummary.queryOptions({
input: {
filter: { search: debouncedQuery, status: statusFilter || undefined },
},
}),
api.invoices.collectedSummary.queryOptions({ input: { filter: summaryFilter } }),
);
/**
* The export's `daterange` filters are coerced from calendar days, while the
* list takes ISO instants — hand the dialog the local day each bound falls
* on so an exported file covers the same range the screen shows.
*/
const exportParams = useMemo(() => {
const out: Record<string, unknown> = { ...controls.params };
for (const key of EXPORT_DAY_KEYS) {
if (typeof out[key] === "string") out[key] = isoToLocalDateStr(out[key] as string);
}
return out;
}, [controls.params]);
const { data: exchangeSettings } = useExchangeSettingsQuery();
const etbCollected = summary?.ETB ?? 0;
const usdCollected = summary?.USD ?? 0;
@@ -236,45 +339,14 @@ 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 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" },
]}
/>
<FilterBar
defs={INVOICE_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search invoice, customer, booking ref, GRN or shipping line…"
sortOptions={SORT_OPTIONS}
viewId="invoices"
>
<ExportButton datasetKey="invoices" params={exportParams} size="sm" />
<ActionIcon
variant="default"
size="lg"
@@ -285,7 +357,7 @@ export default function InvoicesPanel() {
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
</FilterBar>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
@@ -296,7 +368,9 @@ export default function InvoicesPanel() {
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={
debouncedQuery ? "No invoices match your search." : "No invoices yet."
controls.activeCount > 0
? "No invoices match these filters."
: "No invoices yet."
}
error={
isError
@@ -306,18 +380,7 @@ export default function InvoicesPanel() {
}
: 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}
/>

View File

@@ -313,6 +313,10 @@ export interface CompanyListFilter {
type?: CompanyType;
kind?: CompanyKind;
status?: CompanyStatus;
nationality?: CompanyNationality;
/** ISO instants — inclusive bounds on the registration date. */
createdFrom?: string;
createdTo?: string;
/** `true` = submitted applications only; `false` = drafts only; omit for both. */
onboardingCompleted?: boolean;
/**

View File

@@ -24,15 +24,39 @@ export interface Invoice extends Freight.IInvoice {
sourceRef?: InvoiceSourceRef | null;
}
/** Query parameters for the invoice list. */
/**
* Query parameters for the invoice list. Every key maps 1:1 onto
* `FilterInvoiceDto` on the API — the list endpoint runs with
* `forbidNonWhitelisted`, so a param that isn't declared there is a 400, not a
* silently ignored extra.
*/
export interface InvoiceListFilter {
page: number;
pageSize: number;
companyId?: string;
/** Single status — kept for the worklists that pin one. */
status?: Freight.InvoiceStatus;
/** CSV multi-select status, as the filter bar sends it. */
statuses?: string;
/** CSV of `Freight.InvoiceSource` values. */
sources?: string;
/** CSV of EIMS filing states. */
eimsStatuses?: string;
search?: string;
/** Manual-payments worklist only. */
currency?: "USD" | "ETB";
/** ISO instants — inclusive bounds on `issuedAt` / `dueAt`. */
issuedFrom?: string;
issuedTo?: string;
dueFrom?: string;
dueTo?: string;
minAmount?: number;
maxAmount?: number;
/** Outstanding balance only. */
hasBalance?: boolean;
/** Outstanding AND past due — computed, not read off `status`. */
overdue?: boolean;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}
/** Standard paginated list envelope (matches the customers/bookings service shape). */