Files
edr-platform/apps/edr-freight-web/backoffice/src/hooks/useListControls.ts

165 lines
5.9 KiB
TypeScript

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<T> {
/**
* 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<string, unknown>)[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 = <T,>(rows: T[], options: ListControlsOptions<T> = {}) => {
const { searchKeys = [], dateKey, pageSize = 10, searchValue } = options;
const [search, setSearch] = useState("");
const [dateFrom, setDateFrom] = useState<string | null>(null);
const [dateTo, setDateTo] = useState<string | null>(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 <DataTable /> 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,
},
},
};
};