feat(filter-bar): fix pageSize bug, add client bridge, migrate 4 more pages

fix(useFilters): pageSize was a hardcoded constant, never read from the
URL, with no setter — the DataTable/RuleEngineListFooter page-size
dropdown silently did nothing on every page using the filter bar,
including the already-shipped ContractRequestsPage pilot. Added a `size`
URL param (mirrors `page`), `setPageSize`, and wired
`tableProps().onPaginationChange` to route page-size vs page-index
changes to the right setter instead of only ever calling setPage().

feat(clientFilter): applyClientFilters — the Family-B bridge the plan
called for. Generalizes ListControls'/useListControls' one hardcoded
search box + one date range to every FilterDef, matched against
`row[def.key]`. Reuses matchesDayRange/toDayString from
hooks/useListControls.ts (imported, not duplicated) so the inclusive-
range/timezone-safe semantics stay defined in exactly one place. Lets a
page ship the full pill-bar UI immediately and flip to server-side
filtering later by deleting one function call — no endpoint changes
required up front.

Migrated to the filter bar (mechanical, pattern established by
ContractRequestsPage):
- WarehouseInvoicesPage, LoadedInventoryPage, TrucksOnSitePage — Family B
  (ListControls/useListControls → FilterBar + applyClientFilters)
- CustomersPage — Family A (useState bag → useFilters), no filter pills
  needed here (search + sort only), the SegmentedControl "view" stays
  page-level tab state (like ContractStatusTabs), not a filter pill —
  it now resets the page via controls.setPage(1) on change, the same
  hazard useFilters already guards its own filters against
This commit is contained in:
Nathnael
2026-08-14 14:03:33 +00:00
parent a15d4d922e
commit d94c444a51
7 changed files with 234 additions and 147 deletions

View File

@@ -0,0 +1,93 @@
import { matchesDayRange, toDayString } from "@/hooks/useListControls";
import type { FilterDef, FilterValue } from "./types";
export { matchesDayRange, toDayString };
const readField = (row: unknown, key: string): unknown =>
row && typeof row === "object" ? (row as Record<string, unknown>)[key] : undefined;
export interface ClientFilterOptions<T> {
/** Row fields matched against the free-text search box. */
searchKeys?: (keyof T)[];
/** Custom search extractor when the value isn't a top-level field. */
searchValue?: (row: T) => string;
}
/**
* Client-side bridge for pages whose endpoint doesn't (yet) accept
* filter/sort/pagination params — the Family-B pages this app inherited from
* `ListControls`/`useListControls`. Same idea, generalized: instead of one
* hardcoded search box + one date range, every `FilterDef` is matched
* against `row[def.key]` (override the def's `key` to line up with the row
* shape, or filter/map the rows before calling this).
*
* Flip a page to server mode later by deleting the `applyClientFilters` call
* and passing `controls.params` straight to the API — `useFilters`'s output
* shape doesn't change either way.
*
* ponytail: linear scan per keystroke, no debounce — matches
* `useListControls`'s existing behavior at this data size (~1k rows,
* `useListControls.ts:4-18`). Move to server-side filtering if a list
* outgrows that.
*/
export function applyClientFilters<T>(
rows: T[],
defs: FilterDef[],
values: Record<string, FilterValue>,
searchText: string,
options: ClientFilterOptions<T> = {},
): T[] {
const term = searchText.trim().toLowerCase();
const { searchKeys = [], searchValue } = options;
return rows.filter((row) => {
if (term) {
const haystack = searchValue
? searchValue(row)
: searchKeys.map((k) => String(readField(row, String(k)) ?? "")).join(" ");
if (!haystack.toLowerCase().includes(term)) return false;
}
for (const def of defs) {
const value = values[def.key];
if (!value) continue;
if (!matchesFilter(def, value, readField(row, def.key))) return false;
}
return true;
});
}
function matchesFilter(def: FilterDef, value: FilterValue, raw: unknown): boolean {
switch (def.type) {
case "enum": {
const inSet = value.v.includes(String(raw ?? ""));
return value.op === "isNot" ? !inSet : inSet;
}
case "date": {
if (value.op === "between") {
return matchesDayRange(raw, value.v[0]?.slice(0, 10) ?? null, value.v[1]?.slice(0, 10) ?? null);
}
const day = toDayString(raw);
const target = value.v[0]?.slice(0, 10);
if (!day || !target) return false;
return value.op === "before" ? day <= target : day >= target;
}
case "number": {
const num = Number(raw);
if (Number.isNaN(num)) return false;
if (value.op === "between") {
const [min, max] = value.v.map(Number);
return num >= min && num <= max;
}
return value.op === "isNot" ? num !== Number(value.v[0]) : num === Number(value.v[0]);
}
case "boolean":
return Boolean(raw) === (value.v[0] === "true");
case "text": {
const rawStr = String(raw ?? "").toLowerCase();
const target = (value.v[0] ?? "").toLowerCase();
return value.op === "isNot" ? !rawStr.includes(target) : rawStr.includes(target);
}
default:
return true;
}
}

View File

@@ -2,6 +2,7 @@ export * from "./types";
export * from "./url";
export * from "./dates";
export * from "./format";
export * from "./clientFilter";
export * from "./useFilters";
export * from "./useSavedViews";
export { FilterBar } from "./FilterBar";

View File

@@ -32,6 +32,7 @@ export interface UseFilters {
clearFilters: () => void;
setSort: (s: string) => void;
setPage: (p: number) => void;
setPageSize: (size: number) => void;
activeCount: number;
/** Spread onto <DataTable/>. Same shape useListControls.tableProps returns today. */
tableProps: (total: number) => Pick<DataTableProps<any, any>, "pagination" | "tableOptions">;
@@ -55,11 +56,12 @@ export function useFilters(defs: FilterDef[], options: UseFiltersOptions = {}):
const [sp, setSp] = useSearchParams();
const searchKey = ns ? `${ns}.q` : "q";
const pageKey = ns ? `${ns}.page` : "page";
const sizeKey = ns ? `${ns}.size` : "size";
const values = useMemo(() => parseFilters(defs, sp, ns), [defs, sp, ns]);
const sort = sp.get(ns ? `${ns}.sort` : "sort") ?? defaultSort;
const page = Math.max(1, Number(sp.get(pageKey)) || 1);
const pageSize = defaultPageSize;
const pageSize = Math.max(1, Number(sp.get(sizeKey)) || defaultPageSize);
// Free text: local draft debounced into the URL with `replace`, so typing
// leaves exactly one history entry instead of one per keystroke.
@@ -144,6 +146,19 @@ export function useFilters(defs: FilterDef[], options: UseFiltersOptions = {}):
[pageKey, setSp],
);
const setPageSize = useCallback(
(size: number) => {
setSp((prev) => {
const next = new URLSearchParams(prev);
if (size === defaultPageSize) next.delete(sizeKey);
else next.set(sizeKey, String(size));
next.delete(pageKey); // a different page size invalidates the current page index
return next;
});
},
[defaultPageSize, sizeKey, pageKey, setSp],
);
const params = useMemo(() => {
const filterParams = toApiParams(defs, values);
const base: Record<string, string | number | undefined> =
@@ -177,12 +192,13 @@ export function useFilters(defs: FilterDef[], options: UseFiltersOptions = {}):
onPaginationChange: (updater) => {
const current = { pageIndex: page - 1, pageSize };
const next = typeof updater === "function" ? updater(current) : updater;
setPage(next.pageIndex + 1);
if (next.pageSize !== pageSize) setPageSize(next.pageSize);
else if (next.pageIndex !== current.pageIndex) setPage(next.pageIndex + 1);
},
},
};
},
[page, pageSize, setPage],
[page, pageSize, setPage, setPageSize],
);
const applyQueryString = useCallback(
@@ -209,6 +225,7 @@ export function useFilters(defs: FilterDef[], options: UseFiltersOptions = {}):
clearFilters,
setSort,
setPage,
setPageSize,
activeCount,
tableProps,
applyQueryString,

View File

@@ -5,13 +5,10 @@ import {
Card,
Group,
SegmentedControl,
Select,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import {
Building2,
@@ -22,10 +19,8 @@ import {
Mail,
Phone,
RefreshCw,
Search,
ShieldOff,
Users,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
@@ -40,12 +35,8 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type { Company, CompanyStatus } from "@/types/customer";
import { isOnboardingDraft } from "@/types/customer";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
import { FilterBar, useFilters, type FilterDef } from "@/components/filters";
/**
* The list's segmented views. "Pending approval" means submitted-and-awaiting-
@@ -92,28 +83,29 @@ 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[] = [];
export default function CustomersPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [view, setView] = useState<CustomerView>("all");
const [sort, setSort] = useState<string>("review:DESC");
const controls = useFilters(NO_FILTER_DEFS, { defaultSort: "review:DESC", pageSize: 10 });
const filter = useMemo(() => {
const [sortBy, sortOrder] = sort.split(":") as [
const [sortBy, sortOrder] = controls.sort.split(":") as [
"review" | "name" | "createdAt" | "updatedAt",
"ASC" | "DESC",
];
return {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
page: controls.page,
pageSize: controls.pageSize,
search: String(controls.params.search ?? ""),
sortBy,
sortOrder,
...VIEW_FILTERS[view],
};
}, [pagination.pageIndex, pagination.pageSize, debouncedQuery, view, sort]);
}, [controls.page, controls.pageSize, controls.params.search, controls.sort, view]);
const { data: stats } = useQuery(
api.customers.stats.queryOptions({ input: {} }),
@@ -125,7 +117,6 @@ export default function CustomersPage() {
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const columns: ColumnDef<Company>[] = useMemo(
() => [
@@ -295,35 +286,24 @@ export default function CustomersPage() {
<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 company, TIN, email or profile reference…"
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"
/>
<FilterBar
defs={NO_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);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
controls.setPage(1);
}}
data={[
{ label: "All", value: "all" },
@@ -333,21 +313,7 @@ export default function CustomersPage() {
{ label: "Active", value: "active" },
]}
/>
<Select
size="sm"
radius="md"
w={160}
allowDeselect={false}
aria-label="Sort customers"
value={sort}
onChange={(v) => {
if (!v) return;
setSort(v);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={SORT_OPTIONS.map((o) => ({ ...o }))}
/>
</Group>
</FilterBar>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
@@ -358,7 +324,7 @@ export default function CustomersPage() {
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/customers/${row.id}`)}
emptyMessage={
debouncedQuery
controls.searchText
? "No companies match your search."
: "No companies yet."
}
@@ -370,18 +336,7 @@ export default function CustomersPage() {
}
: 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

@@ -1,7 +1,6 @@
import { Badge, Card, Group, Text } from '@mantine/core';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import ListControls from '@/components/common/ListControls';
import { useListControls } from '@/hooks/useListControls';
import { applyClientFilters, FilterBar, useFilters, type FilterDef } from '@/components/filters';
import { PageContainer, PageHeader } from '@/components/page';
import { useQuery } from '@tanstack/react-query';
@@ -63,16 +62,27 @@ const columns: ColumnDef<Loading>[] = [
},
];
const LOADING_FILTER_DEFS: FilterDef[] = [{ key: 'loadedAt', label: 'Loaded', type: 'date' }];
/** Record of every inventory item loaded onto a wagon. */
export default function LoadedInventoryPage() {
const { data, isLoading } = useQuery(
api.warehouses.loadings.queryOptions({ input: {} }),
);
const loadings = data ?? [];
const controls = useListControls(loadings, {
searchKeys: ['wagonNumber'],
dateKey: 'loadedAt',
});
const controls = useFilters(LOADING_FILTER_DEFS, { pageSize: 10 });
// Endpoint takes no params at all — everything filters client-side.
const filteredLoadings = applyClientFilters(
loadings,
LOADING_FILTER_DEFS,
controls.values,
controls.searchText,
{ searchKeys: ['wagonNumber'] },
);
const pagedLoadings = filteredLoadings.slice(
(controls.page - 1) * controls.pageSize,
controls.page * controls.pageSize,
);
return (
<PageContainer>
@@ -90,24 +100,18 @@ export default function LoadedInventoryPage() {
/>
) : (
<>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
<FilterBar
defs={LOADING_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search by wagon…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Loaded"
hasFilters={controls.hasFilters}
onReset={controls.reset}
viewId="loaded-inventory"
/>
<DataTable
columns={columns}
data={controls.pagedRows}
data={pagedLoadings}
status={isLoading ? 'loading' : 'success'}
containerClassName="border-0 shadow-none"
{...controls.tableProps}
{...controls.tableProps(filteredLoadings.length)}
/>
</>
)}

View File

@@ -11,11 +11,10 @@ import {
} from "@mantine/core";
import { PageContainer, PageHeader } from "@/components/page";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path; reused here rather than adding a second one.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import { applyClientFilters, FilterBar, useFilters, type FilterDef } from "@/components/filters";
import { useTrucksOnSite } from "@/hooks/useWarehouses";
import type { TruckOnSite } from "@/types/warehouse";
import { formatDateTime } from "@/lib/format";
@@ -142,10 +141,14 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
);
}
const TRUCK_FILTER_DEFS: FilterDef[] = [{ key: "arrivedAt", label: "Arrived", type: "date" }];
export default function TrucksOnSitePage() {
const { data: trucks = [], isLoading } = useTrucksOnSite();
// The dashboard's "Trucks on-site" card counts only arrived trucks, so it
// deep-links here with ?scope=ON_SITE to land on the matching tab.
// deep-links here with ?scope=ON_SITE to land on the matching tab. Read
// once at mount, same as before — scope/source stay page-level tab state
// (mutually exclusive, dashboard-linked), not filter pills.
const [searchParams] = useSearchParams();
const scopeParam = searchParams.get("scope");
const [scope, setScope] = useState<"ALL" | "ON_SITE" | "INBOUND">(
@@ -153,7 +156,7 @@ export default function TrucksOnSitePage() {
);
const [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL");
// Scope/source are page filters and run first; the shared control then does
// Scope/source are page filters and run first; the filter bar then does
// search + arrival-date range + pagination over what they leave.
const scoped = useMemo(
() =>
@@ -163,10 +166,14 @@ export default function TrucksOnSitePage() {
[trucks, scope, source],
);
const controls = useListControls(scoped, {
const controls = useFilters(TRUCK_FILTER_DEFS, { pageSize: 10 });
const filteredTrucks = applyClientFilters(scoped, TRUCK_FILTER_DEFS, controls.values, controls.searchText, {
searchKeys: ["plateNumber", "driverName", "bookingReference", "customerName", "containers"],
dateKey: "arrivedAt",
});
const pagedTrucks = filteredTrucks.slice(
(controls.page - 1) * controls.pageSize,
controls.page * controls.pageSize,
);
const onSiteCount = trucks.filter((t) => t.status === "ON_SITE").length;
const inboundCount = trucks.length - onSiteCount;
@@ -185,7 +192,10 @@ export default function TrucksOnSitePage() {
<SegmentedControl
size="xs"
value={scope}
onChange={(v) => setScope(v as typeof scope)}
onChange={(v) => {
setScope(v as typeof scope);
controls.setPage(1);
}}
data={[
{ label: `All (${trucks.length})`, value: "ALL" },
{ label: `On site (${onSiteCount})`, value: "ON_SITE" },
@@ -195,7 +205,10 @@ export default function TrucksOnSitePage() {
<SegmentedControl
size="xs"
value={source}
onChange={(v) => setSource(v as typeof source)}
onChange={(v) => {
setSource(v as typeof source);
controls.setPage(1);
}}
data={[
{ label: "All", value: "ALL" },
{ label: `Customer (${customerCount})`, value: "CUSTOMER" },
@@ -205,30 +218,29 @@ export default function TrucksOnSitePage() {
</Group>
</Group>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
<FilterBar
defs={TRUCK_FILTER_DEFS}
controls={controls}
searchPlaceholder="Plate, driver, booking, container…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Arrived"
hasFilters={controls.hasFilters}
onReset={controls.reset}
viewId="trucks-on-site"
/>
{isLoading ? (
<Text size="sm">Loading</Text>
) : (
<>
<Rows rows={controls.pagedRows} />
<Rows rows={pagedTrucks} />
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
pagination={{ pageIndex: controls.page - 1, pageSize: controls.pageSize }}
pageCount={Math.max(1, Math.ceil(filteredTrucks.length / controls.pageSize))}
totalCount={filteredTrucks.length}
itemLabel="trucks"
onPaginationChange={controls.setPagination}
onPaginationChange={(updater) => {
const current = { pageIndex: controls.page - 1, pageSize: controls.pageSize };
const next = typeof updater === "function" ? updater(current) : updater;
if (next.pageSize !== controls.pageSize) controls.setPageSize(next.pageSize);
else controls.setPage(next.pageIndex + 1);
}}
/>
</>
)}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
ActionIcon,
Badge,
@@ -18,8 +18,7 @@ import {
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import ListControls from '@/components/common/ListControls';
import { useListControls } from '@/hooks/useListControls';
import { applyClientFilters, FilterBar, useFilters, type FilterDef } from '@/components/filters';
import { PageContainer, PageHeader } from '@/components/page';
import { AccrualDashboard } from '@/components/warehouses';
@@ -54,9 +53,21 @@ const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
const fmt = (n: number, c: string) => formatMoney(n, c, 2);
const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
const INVOICE_FILTER_DEFS: FilterDef[] = [
{
key: 'status',
label: 'Status',
type: 'enum',
multiple: false,
options: WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') })),
},
{ key: 'issuedAt', label: 'Issued', type: 'date' },
];
export default function WarehouseInvoicesPage() {
const [status, setStatus] = useState<WarehouseInvoiceStatus | null>(null);
const [detailId, setDetailId] = useState<string | null>(null);
const controls = useFilters(INVOICE_FILTER_DEFS, { pageSize: 10 });
const status = (controls.values.status?.v[0] as WarehouseInvoiceStatus | undefined) ?? null;
const { data, isLoading } = useQuery(
api.warehouses.invoices.queryOptions({
@@ -65,10 +76,20 @@ export default function WarehouseInvoicesPage() {
);
const invoices = data ?? [];
const controls = useListControls(invoices, {
searchKeys: ['invoiceNumber', 'bookingReference', 'customerName', 'containerNumber'],
dateKey: 'issuedAt',
});
// Endpoint only filters by `status`; search + issued-date range are
// applied client-side (the client bridge — see components/filters/clientFilter.ts).
// Flip to server mode by deleting this call once the endpoint takes more params.
const filteredInvoices = applyClientFilters(
invoices,
INVOICE_FILTER_DEFS,
controls.values,
controls.searchText,
{ searchKeys: ['invoiceNumber', 'bookingReference', 'customerName', 'containerNumber'] },
);
const pagedInvoices = useMemo(
() => filteredInvoices.slice((controls.page - 1) * controls.pageSize, controls.page * controls.pageSize),
[filteredInvoices, controls.page, controls.pageSize],
);
const invoiceColumns: ColumnDef<WarehouseFeeInvoice>[] = [
{
@@ -135,36 +156,20 @@ export default function WarehouseInvoicesPage() {
</Stack>
<Card>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
<FilterBar
defs={INVOICE_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search invoice no / booking / customer"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Issued"
hasFilters={controls.hasFilters}
onReset={controls.reset}
>
<Select
label="Status"
placeholder="All statuses"
data={WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') }))}
value={status}
onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)}
clearable
w={200}
/>
</ListControls>
viewId="warehouse-invoices"
/>
<DataTable
columns={invoiceColumns}
data={controls.pagedRows}
data={pagedInvoices}
status={isLoading ? 'loading' : 'success'}
emptyMessage="No invoices found."
containerClassName="border-0 shadow-none"
{...controls.tableProps}
{...controls.tableProps(filteredInvoices.length)}
/>
</Card>