mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 09:30:59 +00:00
165 lines
5.9 KiB
TypeScript
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,
|
|
},
|
|
},
|
|
};
|
|
};
|