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
73
components/dashboard/active-engineers.tsx
Normal file
73
components/dashboard/active-engineers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
components/dashboard/kpi-card.tsx
Normal file
123
components/dashboard/kpi-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
components/dashboard/queue-heatmap.tsx
Normal file
134
components/dashboard/queue-heatmap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
components/dashboard/resolution-trend.tsx
Normal file
75
components/dashboard/resolution-trend.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
components/dashboard/volume-trend.tsx
Normal file
78
components/dashboard/volume-trend.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue