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:
parent
1112a06afe
commit
9bfb57553d
75 changed files with 9352 additions and 1827 deletions
|
|
@ -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>
|
||||
));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue