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,73 @@
/* ActiveEngineers top engineers today by hours logged.
*
* Compact list: name + ticket count + hours bar. Sorted by hours
* desc upstream. Empty when no time has been logged yet today. */
'use client';
import { Activity } from 'lucide-react';
import { EmptyState } from '@/components/ui/empty-state';
interface Engineer {
resourceId: string;
name: string;
hours: number;
ticketsTouched: number;
}
interface ActiveEngineersProps {
data: Engineer[];
}
export function ActiveEngineers({ data }: ActiveEngineersProps) {
if (data.length === 0) {
return (
<EmptyState
icon={Activity}
title="No time logged today"
description="Engineers will appear here as they post time entries."
size="sm"
/>
);
}
const max = data.reduce((m, e) => Math.max(m, e.hours), 0) || 1;
const totalHours = data.reduce((s, e) => s + e.hours, 0);
const totalTickets = data.reduce((s, e) => s + e.ticketsTouched, 0);
return (
<div className="space-y-1">
{data.map((e) => {
const pct = (e.hours / max) * 100;
return (
<div key={e.resourceId} className="grid grid-cols-[1fr_auto] items-center gap-3 py-1">
<div className="min-w-0">
<div className="text-sm font-medium truncate">{e.name}</div>
<div className="relative h-1 w-full bg-muted rounded-sm mt-1 overflow-hidden">
<div
className="absolute inset-y-0 left-0 bg-primary/70"
style={{ width: `${pct}%` }}
/>
</div>
</div>
<div className="text-right shrink-0">
<div className="num text-sm">{e.hours.toFixed(1)}h</div>
<div className="text-xs text-muted-foreground">
<span className="num">{e.ticketsTouched}</span>{' '}
ticket{e.ticketsTouched === 1 ? '' : 's'}
</div>
</div>
</div>
);
})}
<div className="border-t pt-2 mt-2 flex justify-between text-xs text-muted-foreground">
<span>Total today</span>
<span>
<span className="num">{totalHours.toFixed(1)}h</span>{' '}
across <span className="num">{totalTickets}</span>{' '}
ticket{totalTickets === 1 ? '' : 's'}
</span>
</div>
</div>
);
}

View file

@ -0,0 +1,123 @@
/* KpiCard primary unit of the operations dashboard.
*
*
* LABEL
* 1,247 12 vs yesterday
* optional caption
*
*
* Numerics use the .num-xl utility (Plex Mono, tabular-nums, large).
* Tone "accent" gets a 2px Wulf-blue left border.
* Tone "warn" gets the destructive border when value > 0. */
import Link from 'next/link';
import { ArrowRight, ArrowUpRight, ArrowDownRight, Minus } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card';
export type KpiTone = 'default' | 'accent' | 'warn' | 'attention';
interface KpiDelta {
/** Numeric delta (+ for up, - for down). */
value: number;
/** Suffix shown after the delta. e.g. "vs yesterday". */
label?: string;
/** When true, "down" is good (e.g., SLA breaches). */
invertedSentiment?: boolean;
}
interface KpiCardProps {
label: string;
/** Display value. number formatted with locale, string passed through. null → em-dash. */
value: number | string | null | undefined;
delta?: KpiDelta;
caption?: React.ReactNode;
tone?: KpiTone;
href?: string;
loading?: boolean;
}
const TONE_STYLES: Record<KpiTone, string> = {
default: 'border-l-transparent',
accent: 'border-l-primary',
warn: 'border-l-amber-500',
attention: 'border-l-destructive',
};
export function KpiCard({
label,
value,
delta,
caption,
tone = 'default',
href,
loading = false,
}: KpiCardProps) {
const display =
value === null || value === undefined
? '—'
: typeof value === 'number'
? value.toLocaleString()
: value;
const inner = (
<Card
className={cn(
'h-full border-l-2 transition-shadow',
TONE_STYLES[tone],
href && 'hover:shadow-sm',
)}
>
<CardContent className="pt-4 pb-3 flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="metric-label">{label}</span>
{href && <ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />}
</div>
<div className="flex items-baseline gap-3">
{loading ? (
<span className="h-9 w-24 animate-pulse rounded bg-muted" />
) : (
<span className="num-xl">{display}</span>
)}
{delta && !loading && <DeltaIndicator delta={delta} />}
</div>
{caption && !loading && (
<div className="text-xs text-muted-foreground">{caption}</div>
)}
</CardContent>
</Card>
);
return href ? (
<Link href={href} className="block">
{inner}
</Link>
) : (
inner
);
}
function DeltaIndicator({ delta }: { delta: KpiDelta }) {
const { value, label, invertedSentiment = false } = delta;
if (value === 0) {
return (
<span className="num text-xs text-muted-foreground inline-flex items-center gap-1">
<Minus className="h-3 w-3" />
{label ?? 'no change'}
</span>
);
}
const positive = value > 0;
const good = invertedSentiment ? !positive : positive;
const colorClass = good
? 'text-emerald-600 dark:text-emerald-400'
: 'text-destructive';
const Icon = positive ? ArrowUpRight : ArrowDownRight;
return (
<span className={cn('num text-xs inline-flex items-center gap-1', colorClass)}>
<Icon className="h-3 w-3" />
{Math.abs(value)}
{label && <span className="text-muted-foreground ml-1">{label}</span>}
</span>
);
}

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>
);
}

View file

@ -0,0 +1,75 @@
/* ResolutionTrend line chart of average resolution hours per day completed,
* last 30 days. Single series, Wulf Blue stroke. */
'use client';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
interface ResolutionPoint {
date: string;
avgHours: number | null;
}
interface ResolutionTrendProps {
data: ResolutionPoint[];
height?: number;
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
export function ResolutionTrend({ data, height = 180 }: ResolutionTrendProps) {
return (
<ResponsiveContainer width="100%" height={height}>
<LineChart data={data} margin={{ top: 4, right: 8, bottom: 4, left: 0 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="2 4" vertical={false} />
<XAxis
dataKey="date"
tickFormatter={fmtDate}
interval="preserveStartEnd"
minTickGap={48}
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
axisLine={false}
tickLine={false}
width={32}
tickFormatter={(v: number) => `${v}h`}
/>
<Tooltip
contentStyle={{
background: 'var(--popover)',
border: '1px solid var(--border)',
borderRadius: 6,
fontSize: 12,
}}
labelFormatter={(value) => fmtDate(String(value))}
formatter={(value) =>
value == null ? ['—', 'avg'] : [`${Number(value).toFixed(1)} h`, 'avg']
}
/>
<Line
type="monotone"
dataKey="avgHours"
stroke="var(--primary)"
strokeWidth={2}
dot={false}
connectNulls
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
);
}

View file

@ -0,0 +1,78 @@
/* VolumeTrend area chart of tickets opened per day for the last 30 days.
*
* Single series, Wulf Blue fill at 20%. No grid, minimal axes the
* shape is what matters. Tooltip carries the exact count + date. */
'use client';
import {
Area,
AreaChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
interface VolumePoint {
date: string;
count: number;
}
interface VolumeTrendProps {
data: VolumePoint[];
height?: number;
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
export function VolumeTrend({ data, height = 180 }: VolumeTrendProps) {
return (
<ResponsiveContainer width="100%" height={height}>
<AreaChart data={data} margin={{ top: 4, right: 8, bottom: 4, left: 0 }}>
<defs>
<linearGradient id="volumeFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--primary)" stopOpacity={0.35} />
<stop offset="100%" stopColor="var(--primary)" stopOpacity={0.02} />
</linearGradient>
</defs>
<XAxis
dataKey="date"
tickFormatter={fmtDate}
interval="preserveStartEnd"
minTickGap={48}
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
axisLine={false}
tickLine={false}
width={28}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
background: 'var(--popover)',
border: '1px solid var(--border)',
borderRadius: 6,
fontSize: 12,
}}
labelFormatter={(value) => fmtDate(String(value))}
formatter={(value) => [value ?? 0, 'opened']}
/>
<Area
type="monotone"
dataKey="count"
stroke="var(--primary)"
strokeWidth={1.5}
fill="url(#volumeFill)"
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
);
}