wulf-pulse/components/admin/DataTable.tsx
lorentz e1427b62d7 feat(admin): DB-backed integration toggles + sticky cols + dark contrast
Builds on the env-var INTEGRATIONS_DISABLED shipped with the nav-design
overhaul.  Adds a DB-backed admin UI so operators can flip integrations
without editing .env and restarting the container, plus the remaining
visual cleanup items from the design backlog.

Integration toggles
- Migration 081 — integration_settings table (key PK, disabled flag,
  reason, disabled_by audit, disabled_at).  Seeded with all 13 known
  integrations as enabled.
- GET / PATCH /api/admin/integrations — gated by requirePermission
  (admin, access).  PATCH clears the in-process integration-health
  cache so toggles take effect within seconds.
- /admin/integrations admin page with a Switch per integration, optional
  reason input, audit-info subtitle (disabled by, when, why), live
  status light from /api/dashboard/integration-health.
- integration-health service merges env-var disable list with DB rows;
  degrades gracefully if migration unapplied / DB unreachable.
- Wired into the Admin nav dropdown (eight items now).
- CLAUDE.md describes both env + DB sources.

Sticky first column on tables
- Table primitive accepts stickyFirstColumn?: boolean.  When true, TH
  and TD :first-child stay pinned during horizontal scroll, with
  background inheritance preserving hover and selected row tints.
- DataTable exposes the prop too — on by default for paginated tables.
- /addigy-devices opts in.

Dark-mode contrast
- --border lifted from 10% to 14% in .dark; --input from 15% to 18%;
  --sidebar-border to 14%.
- StatusLight outline ring lifted from /10 to /15 (light) and /20 (dark).
- DetailModal empty-cell em-dash lifted from /40 to /70 so missing
  values are legible on dark surfaces.

DESIGN.md
- Closed sticky-first-column, dark-mode contrast, and palette-audit
  items (palette deprioritized — most uses are semantic).
- Skeleton helpers documented as preferred for new code; existing
  ad-hoc patterns left in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:55:22 -04:00

385 lines
12 KiB
TypeScript

/* DataTable — paginated, sortable, optionally expandable list table.
*
* Backed by @tanstack/react-table v8 in **manual** mode: the parent owns
* data fetching, sort + search dispatch, and the page/pageSize state.
* The table itself just renders what it's given and emits user-input
* callbacks.
*
* External API is intentionally stable from the previous custom
* implementation:
*
* <DataTable
* columns={[{ key, label, sortable?, render? }]}
* data={rows}
* totalCount={n}
* page={1} pageSize={25}
* onPageChange={fn}
* onSort={(col, dir) => …} // optional
* onSearch={(q) => …} // optional
* onRowClick={(row) => …} // optional
* isLoading={false} // optional
*
* // NEW since the TanStack rewrite:
* getRowCanExpand={(row) => boolean} // optional, default false
* renderSubRow={(row) => <…>} // optional; required when expandable
* />
*
* The Column shape is the same as before (key/label/sortable/render).
* Internally we translate to ColumnDef so existing consumers keep working
* without code changes. */
'use client';
import { useMemo, useState } from 'react';
import {
type ColumnDef,
type ExpandedState,
type Row,
type SortingState,
flexRender,
getCoreRowModel,
getExpandedRowModel,
useReactTable,
} from '@tanstack/react-table';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state';
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Loader2,
Search,
} from 'lucide-react';
import { cn } from '@/lib/utils';
export interface Column<TData = any> {
key: string;
label: string;
sortable?: boolean;
render?: (value: any, row: TData) => React.ReactNode;
}
export interface DataTableProps<TData = any> {
columns: Column<TData>[];
data: TData[];
totalCount: number;
page: number;
pageSize: number;
onPageChange: (page: number) => void;
onSort?: (column: string, direction: 'asc' | 'desc') => void;
onSearch?: (query: string) => void;
onRowClick?: (row: TData) => void;
isLoading?: boolean;
/** Per-row gate for expansion. Return true to enable a chevron toggle. */
getRowCanExpand?: (row: TData) => boolean;
/** Renders the expanded sub-row body when a row is open. */
renderSubRow?: (row: TData) => React.ReactNode;
/** Empty-state slot. Defaults to a neutral "No results" message. */
emptyTitle?: string;
emptyDescription?: string;
/** Pin the first column when the table scrolls horizontally. Default true. */
stickyFirstColumn?: boolean;
}
export default function DataTable<TData = any>({
columns,
data,
totalCount,
page,
pageSize,
onPageChange,
onSort,
onSearch,
onRowClick,
isLoading = false,
getRowCanExpand,
renderSubRow,
emptyTitle = 'No data found',
emptyDescription = 'Try adjusting your search or filters.',
stickyFirstColumn = true,
}: DataTableProps<TData>) {
const [searchQuery, setSearchQuery] = useState('');
const [sorting, setSorting] = useState<SortingState>([]);
const [expanded, setExpanded] = useState<ExpandedState>({});
const expandable = !!renderSubRow;
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
// Translate the legacy Column shape into TanStack ColumnDef.
const tanstackColumns = useMemo<ColumnDef<TData>[]>(() => {
const cols: ColumnDef<TData>[] = [];
// Lead expansion column when expansion is enabled.
if (expandable) {
cols.push({
id: '__expand',
header: () => null,
cell: ({ row }) =>
row.getCanExpand() ? (
<button
onClick={(e) => {
e.stopPropagation();
row.toggleExpanded();
}}
aria-label={row.getIsExpanded() ? 'Collapse row' : 'Expand row'}
className="inline-flex h-6 w-6 items-center justify-center rounded-sm hover:bg-muted/60"
>
{row.getIsExpanded() ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
)}
</button>
) : null,
size: 32,
});
}
for (const c of columns) {
cols.push({
id: c.key,
accessorKey: c.key,
enableSorting: !!c.sortable,
header: c.label,
cell: ({ row, getValue }) =>
c.render ? c.render(getValue(), row.original) : (getValue() as React.ReactNode),
});
}
return cols;
}, [columns, expandable]);
const table = useReactTable<TData>({
data,
columns: tanstackColumns,
state: { sorting, expanded },
onSortingChange: (updater) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
setSorting(next);
// Defer to caller for actual data fetch.
if (onSort && next.length > 0) {
onSort(next[0].id, next[0].desc ? 'desc' : 'asc');
}
},
onExpandedChange: setExpanded,
getRowCanExpand: getRowCanExpand
? (row) => getRowCanExpand(row.original)
: () => expandable,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
manualPagination: true,
manualSorting: true,
pageCount: totalPages,
});
const handleSearch = () => {
onSearch?.(searchQuery);
};
return (
<div className="space-y-4">
{onSearch && (
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="pl-10"
/>
</div>
<Button onClick={handleSearch} disabled={isLoading}>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
<span className="ml-2">Search</span>
</Button>
</div>
)}
<div className="border rounded-md overflow-hidden bg-card">
<Table stickyFirstColumn={stickyFirstColumn}>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="bg-muted/50 hover:bg-muted/50">
{headerGroup.headers.map((header) => {
const sortable = header.column.getCanSort();
const sortDir = header.column.getIsSorted();
return (
<TableHead
key={header.id}
style={header.column.columnDef.size ? { width: header.column.columnDef.size } : undefined}
className="font-semibold"
>
{header.isPlaceholder ? null : sortable ? (
<Button
variant="ghost"
size="sm"
onClick={header.column.getToggleSortingHandler()}
className="h-8 -ml-3"
>
{flexRender(header.column.columnDef.header, header.getContext())}
<SortIcon dir={sortDir === 'asc' ? 'asc' : sortDir === 'desc' ? 'desc' : null} />
</Button>
) : (
flexRender(header.column.columnDef.header, header.getContext())
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
renderLoadingRows(table)
) : table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell colSpan={tanstackColumns.length} className="py-8">
<EmptyState icon={Search} title={emptyTitle} description={emptyDescription} size="sm" />
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<ExpandableRow
key={row.id}
row={row}
onRowClick={onRowClick}
renderSubRow={renderSubRow}
colSpan={tanstackColumns.length}
/>
))
)}
</TableBody>
</Table>
</div>
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-2">
<div className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground num">
{totalCount === 0 ? 0 : Math.min((page - 1) * pageSize + 1, totalCount)}
</span>{' '}
to <span className="font-medium text-foreground num">
{Math.min(page * pageSize, totalCount)}
</span>{' '}
of <span className="font-medium text-foreground num">{totalCount}</span> results
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
onClick={() => onPageChange(1)}
disabled={page <= 1 || isLoading}
className="h-8 w-8"
aria-label="First page"
>
<ChevronsLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1 || isLoading}
className="h-8 w-8"
aria-label="Previous page"
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="px-3 text-sm font-medium num">
Page {page} of {totalPages}
</span>
<Button
variant="outline"
size="icon"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages || isLoading}
className="h-8 w-8"
aria-label="Next page"
>
<ChevronRight className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => onPageChange(totalPages)}
disabled={page >= totalPages || isLoading}
className="h-8 w-8"
aria-label="Last page"
>
<ChevronsRight className="w-4 h-4" />
</Button>
</div>
</div>
</div>
);
}
function ExpandableRow<TData>({
row,
onRowClick,
renderSubRow,
colSpan,
}: {
row: Row<TData>;
onRowClick?: (row: TData) => void;
renderSubRow?: (row: TData) => React.ReactNode;
colSpan: number;
}) {
const clickable = !!onRowClick;
return (
<>
<TableRow
className={cn(clickable && 'cursor-pointer')}
onClick={() => onRowClick?.(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
{row.getIsExpanded() && renderSubRow && (
<TableRow className="bg-muted/20 hover:bg-muted/20">
<TableCell colSpan={colSpan} className="px-6 py-3">
{renderSubRow(row.original)}
</TableCell>
</TableRow>
)}
</>
);
}
function SortIcon({ dir }: { dir: 'asc' | 'desc' | null }) {
if (dir === 'asc') return <ArrowUp className="ml-2 h-4 w-4" />;
if (dir === 'desc') return <ArrowDown className="ml-2 h-4 w-4" />;
return <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />;
}
function renderLoadingRows<TData>(table: ReturnType<typeof useReactTable<TData>>) {
const cols = table.getAllLeafColumns().length;
return Array.from({ length: 5 }).map((_, i) => (
<TableRow key={`loading-${i}`}>
{Array.from({ length: cols }).map((_, j) => (
<TableCell key={j}>
<Skeleton className="h-5 w-full" />
</TableCell>
))}
</TableRow>
));
}