Adds a reusable useTableControls hook and TableControls/SortableTh components, then wires them into all six admin table pages (users, vendors, vessels, sites, accounts, products). Each page now supports a global search bar, clickable sortable column headers with ↑/↓/⇅ indicators, and role/status filter chips — all purely client-side with no URL params or server round-trips. Server pages continue to fetch the full list and pass it as props to a new *-table.tsx Client Component. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
114 lines
3.1 KiB
TypeScript
114 lines
3.1 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo } from "react";
|
|
|
|
export type SortDir = "asc" | "desc";
|
|
|
|
export interface TableControlsResult<T> {
|
|
search: string;
|
|
setSearch: (v: string) => void;
|
|
sortKey: keyof T | null;
|
|
sortDir: SortDir;
|
|
toggleSort: (key: keyof T) => void;
|
|
activeFilters: string[];
|
|
toggleFilter: (chip: string) => void;
|
|
filtered: T[];
|
|
}
|
|
|
|
export interface UseTableControlsOptions<T> {
|
|
/** All rows from the server. */
|
|
rows: T[];
|
|
/**
|
|
* Return a flat string of all searchable text for a row.
|
|
* Concatenate every visible column value, lower-cased.
|
|
*/
|
|
searchText: (row: T) => string;
|
|
/**
|
|
* Return true if the row matches the given active chip label.
|
|
* Called once per active chip; row must match ALL active chips.
|
|
*/
|
|
chipMatch: (row: T, chip: string) => boolean;
|
|
/**
|
|
* Return a comparable primitive for a sort key so the hook can sort.
|
|
* Strings are compared case-insensitively; numbers/booleans natively.
|
|
*/
|
|
sortValue?: (row: T, key: keyof T) => string | number | boolean | null | undefined;
|
|
/** Default sort column. Pass null to start unsorted. */
|
|
defaultSortKey?: keyof T | null;
|
|
/** Default sort direction. */
|
|
defaultSortDir?: SortDir;
|
|
}
|
|
|
|
export function useTableControls<T>({
|
|
rows,
|
|
searchText,
|
|
chipMatch,
|
|
sortValue,
|
|
defaultSortKey = null,
|
|
defaultSortDir = "asc",
|
|
}: UseTableControlsOptions<T>): TableControlsResult<T> {
|
|
const [search, setSearch] = useState("");
|
|
const [sortKey, setSortKey] = useState<keyof T | null>(defaultSortKey);
|
|
const [sortDir, setSortDir] = useState<SortDir>(defaultSortDir);
|
|
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
|
|
|
const toggleSort = (key: keyof T) => {
|
|
setSortKey((prev) => {
|
|
if (prev === key) {
|
|
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
|
return key;
|
|
}
|
|
setSortDir("asc");
|
|
return key;
|
|
});
|
|
};
|
|
|
|
const toggleFilter = (chip: string) => {
|
|
setActiveFilters((prev) =>
|
|
prev.includes(chip) ? prev.filter((c) => c !== chip) : [...prev, chip]
|
|
);
|
|
};
|
|
|
|
const filtered = useMemo(() => {
|
|
let result = rows;
|
|
|
|
// 1. Search
|
|
const q = search.trim().toLowerCase();
|
|
if (q) {
|
|
result = result.filter((row) => searchText(row).toLowerCase().includes(q));
|
|
}
|
|
|
|
// 2. Chip filters — row must satisfy every active chip
|
|
if (activeFilters.length > 0) {
|
|
result = result.filter((row) => activeFilters.every((chip) => chipMatch(row, chip)));
|
|
}
|
|
|
|
// 3. Sort
|
|
if (sortKey !== null && sortValue) {
|
|
result = [...result].sort((a, b) => {
|
|
const av = sortValue(a, sortKey) ?? "";
|
|
const bv = sortValue(b, sortKey) ?? "";
|
|
let cmp = 0;
|
|
if (typeof av === "string" && typeof bv === "string") {
|
|
cmp = av.toLowerCase().localeCompare(bv.toLowerCase());
|
|
} else {
|
|
cmp = av < bv ? -1 : av > bv ? 1 : 0;
|
|
}
|
|
return sortDir === "asc" ? cmp : -cmp;
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}, [rows, search, activeFilters, sortKey, sortDir, searchText, chipMatch, sortValue]);
|
|
|
|
return {
|
|
search,
|
|
setSearch,
|
|
sortKey,
|
|
sortDir,
|
|
toggleSort,
|
|
activeFilters,
|
|
toggleFilter,
|
|
filtered,
|
|
};
|
|
}
|