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>
141 lines
4.3 KiB
TypeScript
141 lines
4.3 KiB
TypeScript
"use client";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import type { SortDir } from "./use-table-controls";
|
|
|
|
// ─── Search bar ──────────────────────────────────────────────────────────────
|
|
|
|
interface TableSearchProps {
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
placeholder?: string;
|
|
}
|
|
|
|
export function TableSearch({ value, onChange, placeholder = "Search…" }: TableSearchProps) {
|
|
return (
|
|
<div className="relative">
|
|
<svg
|
|
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
strokeWidth={1.5}
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607z"
|
|
/>
|
|
</svg>
|
|
<input
|
|
type="search"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
className="h-9 w-64 rounded-lg border border-neutral-300 bg-white pl-9 pr-3 py-2 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Filter chips ────────────────────────────────────────────────────────────
|
|
|
|
interface FilterChipsProps {
|
|
chips: string[];
|
|
active: string[];
|
|
onToggle: (chip: string) => void;
|
|
}
|
|
|
|
export function FilterChips({ chips, active, onToggle }: FilterChipsProps) {
|
|
return (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{chips.map((chip) => {
|
|
const isActive = active.includes(chip);
|
|
return (
|
|
<button
|
|
key={chip}
|
|
type="button"
|
|
onClick={() => onToggle(chip)}
|
|
className={cn(
|
|
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
|
isActive
|
|
? "border-primary-200 bg-primary-50 text-primary-700"
|
|
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
|
|
)}
|
|
>
|
|
{chip}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── TableControls wrapper ────────────────────────────────────────────────────
|
|
|
|
interface TableControlsProps {
|
|
search: string;
|
|
onSearch: (v: string) => void;
|
|
searchPlaceholder?: string;
|
|
chips: string[];
|
|
activeFilters: string[];
|
|
onToggleFilter: (chip: string) => void;
|
|
}
|
|
|
|
export function TableControls({
|
|
search,
|
|
onSearch,
|
|
searchPlaceholder,
|
|
chips,
|
|
activeFilters,
|
|
onToggleFilter,
|
|
}: TableControlsProps) {
|
|
return (
|
|
<div className="mb-3 flex flex-wrap items-center gap-3">
|
|
<TableSearch value={search} onChange={onSearch} placeholder={searchPlaceholder} />
|
|
{chips.length > 0 && (
|
|
<FilterChips chips={chips} active={activeFilters} onToggle={onToggleFilter} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Sortable column header ───────────────────────────────────────────────────
|
|
|
|
interface SortableThProps extends React.ThHTMLAttributes<HTMLTableCellElement> {
|
|
sortKey: string;
|
|
activeSortKey: string | null;
|
|
sortDir: SortDir;
|
|
onSort: (key: string) => void;
|
|
}
|
|
|
|
export function SortableTh({
|
|
sortKey,
|
|
activeSortKey,
|
|
sortDir,
|
|
onSort,
|
|
children,
|
|
className,
|
|
...rest
|
|
}: SortableThProps) {
|
|
const isActive = activeSortKey === sortKey;
|
|
|
|
return (
|
|
<th
|
|
{...rest}
|
|
className={cn(
|
|
"px-4 py-3 text-left font-medium text-neutral-600 cursor-pointer select-none hover:bg-neutral-50",
|
|
className
|
|
)}
|
|
onClick={() => onSort(sortKey)}
|
|
>
|
|
<span className="inline-flex items-center gap-1">
|
|
{children}
|
|
<span className="text-neutral-400 text-xs">
|
|
{isActive ? (sortDir === "asc" ? "↑" : "↓") : "⇅"}
|
|
</span>
|
|
</span>
|
|
</th>
|
|
);
|
|
}
|