import { useEffect, useMemo, useState } from "react"; import { usePagination } from "@edr/ui-common"; /** * Search + date-range + pagination over an already-fetched array. * * Client-side on purpose: the freight lists are hundreds of rows (largest table * is ~1.1k), so filtering in the browser avoids paginating ~20 API endpoints — * several of which sit on billing paths. If a list ever outgrows this (roughly * 5k rows, where the per-keystroke filter starts to feel slow), move that ONE * page to a server-side query; the component API here stays the same. * * Dates are `YYYY-MM-DD` strings, matching Mantine 9's date inputs. Comparing * them lexically keeps the range on calendar days and sidesteps timezone drift * entirely — a UTC timestamp is truncated to its date before the comparison. * * ponytail: linear scan per keystroke, no debounce — fine at this size; add * a debounce (or server-side filtering) if a list gets big enough to stutter. */ export interface ListControlsOptions { /** * Fields matched against the search box. Constrained to real keys of the row * so a typo is a compile error rather than a filter that silently matches * nothing. For nested or derived values, pass `searchValue` instead. */ searchKeys?: (keyof T)[]; /** * Row's meaningful business date (arrival, invoice, dispatch…), which is what * staff actually filter by. Falls back to `createdAt` when the row has no * value for it, so a record is never silently invisible to a date range. */ dateKey?: keyof T; /** Rows per page. */ pageSize?: number; /** Custom search extractor when the value isn't a top-level field. */ searchValue?: (row: T) => string; } const readField = (row: unknown, key: string): unknown => row && typeof row === "object" ? (row as Record)[key] : undefined; /** * Reduce any stored date to its `YYYY-MM-DD` calendar day. ISO strings are cut * directly rather than parsed, so a timestamp is never shifted into the * previous/next day by the viewer's timezone. */ export const toDayString = (raw: unknown): string | null => { if (!raw) return null; if (raw instanceof Date) { return Number.isNaN(raw.getTime()) ? null : raw.toISOString().slice(0, 10); } const text = String(raw); if (/^\d{4}-\d{2}-\d{2}/.test(text)) return text.slice(0, 10); const parsed = new Date(text); return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString().slice(0, 10); }; /** * Does a stored date fall inside an inclusive `YYYY-MM-DD` range? Exported for * lists that already own their filtering (e.g. FleetResourcePage, which folds * server-side filters and search together) so the range semantics — inclusive * ends, undated rows excluded — stay defined in exactly one place. */ export const matchesDayRange = ( raw: unknown, dateFrom: string | null, dateTo: string | null, ): boolean => { if (!dateFrom && !dateTo) return true; const day = toDayString(raw); if (!day) return false; if (dateFrom && day < dateFrom) return false; if (dateTo && day > dateTo) return false; return true; }; export const useListControls = (rows: T[], options: ListControlsOptions = {}) => { const { searchKeys = [], dateKey, pageSize = 10, searchValue } = options; const [search, setSearch] = useState(""); const [dateFrom, setDateFrom] = useState(null); const [dateTo, setDateTo] = useState(null); const { pagination, setPagination } = usePagination({ pageSize }); const keys = searchKeys.map(String); const keySignature = keys.join("|"); const dateKeyStr = dateKey ? String(dateKey) : undefined; const filteredRows = useMemo(() => { const term = search.trim().toLowerCase(); if (!term && !dateFrom && !dateTo) return rows; return rows.filter((row) => { if (term) { const haystack = searchValue ? searchValue(row) : keys.map((key) => String(readField(row, key) ?? "")).join(" "); if (!haystack.toLowerCase().includes(term)) return false; } if (dateFrom || dateTo) { const raw = dateKeyStr ? (readField(row, dateKeyStr) ?? readField(row, "createdAt")) : null; if (!matchesDayRange(raw, dateFrom, dateTo)) return false; } return true; }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [rows, search, dateFrom, dateTo, keySignature, dateKeyStr, searchValue]); // Narrowing the result set can strand the user on a page that no longer // exists (filter to 3 rows while on page 5 → empty table). Snap back to the // first page whenever the filters change. useEffect(() => { setPagination((prev) => (prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 })); }, [search, dateFrom, dateTo, setPagination]); const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize)); const pagedRows = useMemo(() => { const start = pagination.pageIndex * pagination.pageSize; return filteredRows.slice(start, start + pagination.pageSize); }, [filteredRows, pagination.pageIndex, pagination.pageSize]); const hasFilters = Boolean(search || dateFrom || dateTo); const reset = () => { setSearch(""); setDateFrom(null); setDateTo(null); }; return { search, setSearch, dateFrom, setDateFrom, dateTo, setDateTo, hasFilters, reset, filteredRows, pagedRows, pageCount, pagination, setPagination, totalCount: filteredRows.length, /** Spread straight onto so every list paginates identically. */ tableProps: { pagination: { pageIndex: pagination.pageIndex, pageSize: pagination.pageSize, pageCount, totalCount: filteredRows.length, }, tableOptions: { manualPagination: true as const, pageCount, state: { pagination }, onPaginationChange: setPagination, }, }, }; };