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,