wulf-pulse/components/dashboard/queue-heatmap.tsx

135 lines
3.8 KiB
TypeScript
Raw Normal View History

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>
2026-05-03 09:33:13 -04:00
/* 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>
);
}