wulf-pulse/components/admin/DataTable.tsx
lorentz 9bfb57553d feat(design): nav/visual overhaul — brand layer, /status route, KPI dashboard, TanStack DataTable
Major UI refresh on the nav-design-improvements branch.  Drops 2013-era
inline styles and consolidates patterns behind shared primitives.

Foundation
- New Wulf brand layer in app/styles/brand.css repointing --primary to
  the standards-guide blue (#0075AD) with utility classes for numerics
  (.num / .num-lg / .num-xl), metric labels, surface tints, and the
  wolf-mark watermark
- Switch primary face to IBM Plex Sans + IBM Plex Mono via next/font;
  Helvetica/Arial stays in the fallback chain for brand fidelity
- Wordmark subtitle changed from "PSA Management System" to
  "Operations console" everywhere it appeared
- Tagline footer ("Don't be afraid to cry") on every non-mobile page

Status moved out of /dashboard
- New /status route with integration tiles grouped by category, sync
  health table, worker pulse cards (analyzer / RMM / sync scheduler),
  token-expiry section, conditional alert banner
- Top-bar StatusIndicator polls integration health every 60s and links
  to /status
- INTEGRATIONS_DISABLED env var suppresses operator-disabled
  integrations (e.g. SentinelOne) — no failure noise from broken-on-
  purpose entries.  Aliases supported (sentinelone → s1, etc.)

Dashboard rebuilt around KPIs
- /api/dashboard/overview adds today snapshot (opened, resolved, open
  total, SLA breaches) with delta math
- /api/dashboard/trends backs queue × priority heatmap, 30-day volume
  area chart, 30-day mean resolution time line chart, today's active
  engineers leaderboard

Components
- StatusBadge driven by lib/status-registry.ts (priority, ticket
  status, classification, source, company type, publish, active /
  yes-no / billable / approved registries)
- StatusLight (8px geometric square, five states, three sizes)
- EmptyState (shared dashed panel with icon + headline + optional CTA)
- KpiCard with delta indicator and tonal left border
- WulfMark (mark / wordmark variants from /public/branding)
- Skeleton helpers (SkeletonRow / Rows / Card / Chart / Header / Table)

Navigation
- Admin flat link → dropdown with seven shortcuts
- New UserMenu (initials avatar, role badge, settings + sign-out)
- Active-route highlight is now a 2px Wulf-blue underline echoing the
  PageHeader rule (consistent across flat links and submenu triggers);
  active children inside dropdowns use bg-primary/10
- Submenu width is content-driven (min-w 320 / max-w 440, single col)
- Mobile hamburger via Sheet, reuses the same nav config

Pages migrated
- 16 admin sub-pages adopt PageHeader (with accent prop)
- /addigy-devices: shadcn Table + Checkbox; PageHeader; status badges
- 10 raw <table> blocks across admin/sync/* migrated to shadcn Table
- /veeam-analysis migrated to shadcn Table (kept its expansion logic)
- Detail routes (analyzer ticket, analyzer analysis) get breadcrumbs

DataTable
- Rewritten on @tanstack/react-table v8 in manual mode; external API
  unchanged so all 10+ data-browser pages keep working
- New optional props for drill-down rows: getRowCanExpand + renderSubRow

Mobile
- Multi-select Popover gets max-w-[calc(100vw-1rem)] and
  collisionPadding so dropdowns can't overflow narrow viewports
- CI filter bar wraps and shrinks; stat pill flows below

Docs
- New ARCHITECTURE.md (load-bearing reference for runtime, data flow,
  workers, analyzer pipeline, auth, deployment, gotchas)
- New DESIGN.md (tokens, layout, navigation IA, component vocabulary,
  rolling backlog of remaining cleanup)
- CLAUDE.md refreshed with pointers to the two new docs and the
  INTEGRATIONS_DISABLED operator config note
- shadcn registry registered as project-level MCP server (.mcp.json)

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

382 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;
}
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.',
}: 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>
<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>
));
}