diff --git a/apps/edr-freight-web/backoffice/src/components/filters/clientFilter.ts b/apps/edr-freight-web/backoffice/src/components/filters/clientFilter.ts new file mode 100644 index 000000000..ae0034cbb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/clientFilter.ts @@ -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)[key] : undefined; + +export interface ClientFilterOptions { + /** 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( + rows: T[], + defs: FilterDef[], + values: Record, + searchText: string, + options: ClientFilterOptions = {}, +): 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; + } +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/index.ts b/apps/edr-freight-web/backoffice/src/components/filters/index.ts index 0e3e533bd..5fe7702f3 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/filters/index.ts @@ -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"; diff --git a/apps/edr-freight-web/backoffice/src/components/filters/useFilters.ts b/apps/edr-freight-web/backoffice/src/components/filters/useFilters.ts index c7fe5a2cd..7c4dfbf43 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/useFilters.ts +++ b/apps/edr-freight-web/backoffice/src/components/filters/useFilters.ts @@ -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 . Same shape useListControls.tableProps returns today. */ tableProps: (total: number) => Pick, "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 = @@ -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, diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index 23120f98f..91bb72fdc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -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 (Z–A)" }, ] 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("all"); - const [sort, setSort] = useState("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[] = useMemo( () => [ @@ -295,35 +286,24 @@ export default function CustomersPage() { - - } - value={query} - onChange={(e) => setQuery(e.target.value)} - rightSection={ - query ? ( - setQuery("")} - > - - - ) : null - } - style={{ flex: 1, minWidth: "240px" }} - radius="lg" - /> + ({ ...o }))} + viewId="customers" + > { + // `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" }, ]} /> - ({ value: s, label: s.replace(/_/g, ' ') }))} - value={status} - onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)} - clearable - w={200} - /> - + viewId="warehouse-invoices" + />