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>
This commit is contained in:
lorentz 2026-05-03 09:33:13 -04:00
parent 1112a06afe
commit 9bfb57553d
75 changed files with 9352 additions and 1827 deletions

View file

@ -1,35 +1,100 @@
/* 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 { useState } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Search, ArrowUpDown, ArrowUp, ArrowDown, Loader2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
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';
interface Column {
export interface Column<TData = any> {
key: string;
label: string;
sortable?: boolean;
render?: (value: any, row: any) => React.ReactNode;
render?: (value: any, row: TData) => React.ReactNode;
}
interface DataTableProps {
columns: Column[];
data: any[];
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: any) => 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({
export default function DataTable<TData = any>({
columns,
data,
totalCount,
@ -40,37 +105,97 @@ export default function DataTable({
onSearch,
onRowClick,
isLoading = false,
}: DataTableProps) {
getRowCanExpand,
renderSubRow,
emptyTitle = 'No data found',
emptyDescription = 'Try adjusting your search or filters.',
}: DataTableProps<TData>) {
const [searchQuery, setSearchQuery] = useState('');
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [sorting, setSorting] = useState<SortingState>([]);
const [expanded, setExpanded] = useState<ExpandedState>({});
const totalPages = Math.ceil(totalCount / pageSize);
const expandable = !!renderSubRow;
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
const handleSort = (columnKey: string) => {
if (!onSort) return;
const newDirection = sortColumn === columnKey && sortDirection === 'asc' ? 'desc' : 'asc';
setSortColumn(columnKey);
setSortDirection(newDirection);
onSort(columnKey, newDirection);
};
// 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 = () => {
if (onSearch) {
onSearch(searchQuery);
}
onSearch?.(searchQuery);
};
return (
<div className="space-y-4">
{/* Search Bar */}
{onSearch && (
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search..."
placeholder="Search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
@ -84,92 +209,82 @@ export default function DataTable({
</div>
)}
{/* Table */}
<div className="border rounded-lg overflow-hidden bg-card">
<div className="border rounded-md overflow-hidden bg-card">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
{columns.map((column) => (
<TableHead key={column.key} className="font-semibold">
{column.sortable ? (
<Button
variant="ghost"
size="sm"
onClick={() => handleSort(column.key)}
className="h-8 -ml-3 hover:bg-muted/80 transition-colors"
{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"
>
{column.label}
{sortColumn === column.key ? (
sortDirection === 'asc' ? (
<ArrowUp className="ml-2 h-4 w-4" />
) : (
<ArrowDown className="ml-2 h-4 w-4" />
)
{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>
) : (
<ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
flexRender(header.column.columnDef.header, header.getContext())
)}
</Button>
) : (
column.label
)}
</TableHead>
))}
</TableRow>
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
Array.from({ length: 5 }).map((_, index) => (
<TableRow key={index}>
{columns.map((column) => (
<TableCell key={column.key}>
<Skeleton className="h-5 w-full" />
</TableCell>
))}
</TableRow>
))
) : data.length === 0 ? (
renderLoadingRows(table)
) : table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} className="text-center py-12">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Search className="w-8 h-8 opacity-50" />
<p className="text-sm font-medium">No data found</p>
<p className="text-xs">Try adjusting your search or filters</p>
</div>
<TableCell colSpan={tanstackColumns.length} className="py-8">
<EmptyState icon={Search} title={emptyTitle} description={emptyDescription} size="sm" />
</TableCell>
</TableRow>
) : (
data.map((row, index) => (
<TableRow
key={row.id || index}
className={onRowClick ? 'cursor-pointer hover:bg-muted/50 transition-colors' : ''}
onClick={() => onRowClick?.(row)}
>
{columns.map((column) => (
<TableCell key={column.key}>
{column.render ? column.render(row[column.key], row) : row[column.key]}
</TableCell>
))}
</TableRow>
table.getRowModel().rows.map((row) => (
<ExpandableRow
key={row.id}
row={row}
onRowClick={onRowClick}
renderSubRow={renderSubRow}
colSpan={tanstackColumns.length}
/>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-2">
<div className="text-sm text-muted-foreground font-medium">
Showing <span className="font-semibold text-foreground">{Math.min((page - 1) * pageSize + 1, totalCount)}</span> to{' '}
<span className="font-semibold text-foreground">{Math.min(page * pageSize, totalCount)}</span> of{' '}
<span className="font-semibold text-foreground">{totalCount}</span> results
<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}
disabled={page <= 1 || isLoading}
className="h-8 w-8"
aria-label="First page"
>
<ChevronsLeft className="w-4 h-4" />
</Button>
@ -177,22 +292,22 @@ export default function DataTable({
variant="outline"
size="icon"
onClick={() => onPageChange(page - 1)}
disabled={page === 1 || isLoading}
disabled={page <= 1 || isLoading}
className="h-8 w-8"
aria-label="Previous page"
>
<ChevronLeft className="w-4 h-4" />
</Button>
<div className="flex items-center gap-1 px-3">
<span className="text-sm font-medium">
Page {page} of {totalPages || 1}
</span>
</div>
<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}
disabled={page >= totalPages || isLoading}
className="h-8 w-8"
aria-label="Next page"
>
<ChevronRight className="w-4 h-4" />
</Button>
@ -200,8 +315,9 @@ export default function DataTable({
variant="outline"
size="icon"
onClick={() => onPageChange(totalPages)}
disabled={page === totalPages || isLoading}
disabled={page >= totalPages || isLoading}
className="h-8 w-8"
aria-label="Last page"
>
<ChevronsRight className="w-4 h-4" />
</Button>
@ -210,3 +326,57 @@ export default function DataTable({
</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>
));
}