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>
385 lines
12 KiB
TypeScript
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>
|
|
));
|
|
}
|