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

@ -0,0 +1,134 @@
/* QueueHeatmap open tickets by queue × priority.
*
* Geometric grid; each cell is a square tinted with the brand blue.
* Intensity scales linearly to the most-loaded cell (so the heaviest
* cell renders at full saturation). Per-row totals on the right; the
* column headers carry priority labels.
*
* Empty cells render as a thin dashed outline rather than nothing
* preserves the grid alignment and makes the absence visible. */
'use client';
import { priorityBadge } from '@/lib/status-registry';
import { cn } from '@/lib/utils';
interface HeatmapRow {
queueId: number;
queueLabel: string;
total: number;
byPriority: Record<number, number>;
}
interface QueueHeatmapProps {
data: HeatmapRow[];
/** Priority IDs in column order. Defaults to Critical→Very Low. */
priorities?: number[];
}
const DEFAULT_PRIORITIES = [2, 3, 4, 6, 7];
export function QueueHeatmap({ data, priorities = DEFAULT_PRIORITIES }: QueueHeatmapProps) {
const max = data.reduce(
(m, row) => Math.max(m, ...priorities.map((p) => row.byPriority[p] ?? 0)),
1,
);
if (data.length === 0) {
return (
<p className="text-sm text-muted-foreground py-4">
No open tickets.
</p>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm border-separate border-spacing-x-1 border-spacing-y-0">
<thead>
<tr>
<th className="text-left metric-label py-1 pr-3">Queue</th>
{priorities.map((p) => {
const badge = priorityBadge(p);
return (
<th key={p} className="metric-label py-1 px-1 text-center w-12">
{badge.label.slice(0, 3)}
</th>
);
})}
<th className="metric-label py-1 pl-3 text-right">Total</th>
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={row.queueId} className="hover:bg-muted/30">
<td
className="py-1 pr-3 truncate max-w-[260px]"
title={row.queueLabel}
>
{row.queueLabel}
</td>
{priorities.map((p) => (
<Cell
key={p}
value={row.byPriority[p] ?? 0}
max={max}
priorityLabel={priorityBadge(p).label}
queueLabel={row.queueLabel}
/>
))}
<td className="py-1 pl-3 num text-right text-muted-foreground">
{row.total}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function Cell({
value,
max,
priorityLabel,
queueLabel,
}: {
value: number;
max: number;
priorityLabel: string;
queueLabel: string;
}) {
if (value === 0) {
return (
<td className="py-1 px-1">
<div
className="h-7 w-full rounded-sm border border-dashed border-border/50"
aria-label="0"
/>
</td>
);
}
// Normalize 0..1 then floor into one of 6 opacity steps; smallest cell still
// reads, largest is at 0.85.
const ratio = Math.min(value / max, 1);
const opacity = Math.max(0.18, ratio * 0.85);
return (
<td className="py-1 px-1">
<div
className={cn(
'h-7 w-full rounded-sm flex items-center justify-center num text-xs',
'transition-opacity hover:opacity-100',
)}
style={{
backgroundColor: `color-mix(in oklch, var(--primary) ${Math.round(opacity * 100)}%, transparent)`,
color: ratio > 0.55 ? 'var(--primary-foreground)' : 'var(--foreground)',
}}
title={`${queueLabel} · ${priorityLabel}: ${value}`}
aria-label={`${queueLabel}, ${priorityLabel}: ${value}`}
>
{value}
</div>
</td>
);
}