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>
134 lines
3.8 KiB
TypeScript
134 lines
3.8 KiB
TypeScript
/* 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>
|
||
);
|
||
}
|