pelagia-portal/App/components/ui/use-table-controls.ts
Hardik 9758dcd8ab feat(admin): add client-side search, sort, and filter chips to all admin tables
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>
2026-05-29 02:46:52 +05:30

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,
};
}