wulf-pulse/app/admin/morning-summary/page.tsx

471 lines
20 KiB
TypeScript
Raw Normal View History

'use client';
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import {
Send, RefreshCw, Trash2, Plus, CheckCircle2, XCircle,
AlertTriangle, Clock, Loader2, ChevronDown, ChevronUp, ToggleLeft, ToggleRight,
} from 'lucide-react';
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
import { PageHeader } from '@/components/navigation/page-header';
interface WebhookConfig {
id: number;
label: string;
webhook_url: string;
enabled: boolean;
last_delivered_at: string | null;
last_status: string | null;
created_at: string;
}
interface SummaryConfig {
weekend_suppression: boolean;
monday_extended_window: boolean;
severity_filter: number;
outages_only: boolean;
}
interface SummaryRow {
id: number;
generated_at: string;
open_count: number;
resolved_count: number;
mttr_minutes: number | null;
is_weekend_window: boolean;
delivery_status: Record<string, { success: boolean; httpStatus?: number; error?: string }>;
card_payload?: object;
window_from?: string;
window_to?: string;
clients_affected?: string[];
}
function fmtDate(d: string | null) {
if (!d) return 'Never';
const date = new Date(d);
const diff = Date.now() - date.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function StatusBadge({ status }: { status: string | null }) {
if (!status) return <span className="text-xs text-muted-foreground">Never sent</span>;
if (status === 'success') return (
<span className="flex items-center gap-1 text-xs text-green-500">
<CheckCircle2 className="h-3 w-3" /> Success
</span>
);
return (
<span className="flex items-center gap-1 text-xs text-red-500">
<XCircle className="h-3 w-3" /> Failed
</span>
);
}
export default function MorningSummaryPage() {
const [webhooks, setWebhooks] = useState<WebhookConfig[]>([]);
const [config, setConfig] = useState<SummaryConfig | null>(null);
const [history, setHistory] = useState<SummaryRow[]>([]);
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [testingId, setTestingId] = useState<number | null>(null);
const [toast, setToast] = useState<{ msg: string; ok: boolean } | null>(null);
const [cardExpanded, setCardExpanded] = useState(false);
const [showAddWebhook, setShowAddWebhook] = useState(false);
const [newLabel, setNewLabel] = useState('');
const [newUrl, setNewUrl] = useState('');
const [addingWebhook, setAddingWebhook] = useState(false);
const showToast = (msg: string, ok: boolean) => {
setToast({ msg, ok });
setTimeout(() => setToast(null), 4000);
};
const fetchAll = useCallback(async () => {
setLoading(true);
try {
const [wRes, cRes, hRes] = await Promise.all([
fetch('/api/notifications/morning-summary/webhooks'),
fetch('/api/notifications/morning-summary/config'),
fetch('/api/notifications/morning-summary/history'),
]);
const [wData, cData, hData] = await Promise.all([wRes.json(), cRes.json(), hRes.json()]);
setWebhooks(wData.webhooks ?? []);
setConfig(cData.config ?? null);
setHistory(hData.history ?? []);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchAll(); }, [fetchAll]);
const handleSendAll = async () => {
setSending(true);
try {
const res = await fetch('/api/notifications/morning-summary/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
const ok = data.results?.filter((r: any) => r.success).length ?? 0;
const fail = data.results?.filter((r: any) => !r.success).length ?? 0;
showToast(`Sent — ${ok} succeeded, ${fail} failed`, fail === 0);
fetchAll();
} catch (e) {
showToast(String(e), false);
} finally {
setSending(false);
}
};
const handleTest = async (webhookId: number, label: string) => {
setTestingId(webhookId);
try {
const res = await fetch('/api/notifications/morning-summary/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ webhookId }),
});
const data = await res.json();
showToast(data.result?.success ? `✅ Test sent to ${label}` : `❌ Test failed: ${data.result?.error ?? data.error}`, data.result?.success);
fetchAll();
} catch (e) {
showToast(String(e), false);
} finally {
setTestingId(null);
}
};
const handleToggleWebhook = async (webhook: WebhookConfig) => {
try {
await fetch(`/api/notifications/morning-summary/webhooks/${webhook.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !webhook.enabled }),
});
setWebhooks(prev => prev.map(w => w.id === webhook.id ? { ...w, enabled: !w.enabled } : w));
} catch (e) {
showToast(String(e), false);
}
};
const handleDeleteWebhook = async (id: number) => {
if (!confirm('Delete this webhook?')) return;
try {
await fetch(`/api/notifications/morning-summary/webhooks/${id}`, { method: 'DELETE' });
setWebhooks(prev => prev.filter(w => w.id !== id));
} catch (e) {
showToast(String(e), false);
}
};
const handleAddWebhook = async () => {
if (!newLabel.trim() || !newUrl.trim()) return;
setAddingWebhook(true);
try {
const res = await fetch('/api/notifications/morning-summary/webhooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label: newLabel.trim(), webhook_url: newUrl.trim() }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setWebhooks(prev => [...prev, data.webhook]);
setNewLabel('');
setNewUrl('');
setShowAddWebhook(false);
showToast('Webhook added', true);
} catch (e) {
showToast(String(e), false);
} finally {
setAddingWebhook(false);
}
};
const handleConfigToggle = async (field: keyof SummaryConfig) => {
if (!config) return;
const updated = { ...config, [field]: !config[field] };
setConfig(updated);
try {
await fetch('/api/notifications/morning-summary/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: updated[field] }),
});
} catch (e) {
showToast(String(e), false);
setConfig(config);
}
};
const latestSummary = history[0] ?? null;
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
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
<>
<PageHeader
title="Morning NOC Summary"
description="Scheduled 6:30 AM MonFri · Posts to Teams channels via webhook"
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Morning NOC Summary' }]}
accent
actions={
<>
<Button variant="outline" size="sm" onClick={fetchAll}><RefreshCw className="h-4 w-4 mr-1" /> Refresh</Button>
<Button size="sm" onClick={handleSendAll} disabled={sending}>
{sending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Send className="h-4 w-4 mr-1" />}
Send Now
</Button>
</>
}
/>
<div className="max-w-4xl mx-auto p-6 space-y-8">
{/* Toast */}
{toast && (
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-sm font-medium flex items-center gap-2 ${toast.ok ? 'bg-green-500/10 border border-green-500/30 text-green-400' : 'bg-red-500/10 border border-red-500/30 text-red-400'}`}>
{toast.ok ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
{toast.msg}
</div>
)}
{/* Last Run Stats */}
{latestSummary && (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Last Run</h2>
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" /> {fmtDate(latestSummary.generated_at)}
{latestSummary.is_weekend_window && <span className="ml-2 px-1.5 py-0.5 bg-blue-500/10 text-blue-400 rounded text-xs">Weekend</span>}
</span>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<div className={`text-2xl font-bold ${latestSummary.open_count > 0 ? 'text-red-400' : 'text-muted-foreground'}`}>{latestSummary.open_count}</div>
<div className="text-xs text-muted-foreground">Open</div>
</div>
<div className="text-center">
<div className={`text-2xl font-bold ${latestSummary.resolved_count > 0 ? 'text-green-400' : 'text-muted-foreground'}`}>{latestSummary.resolved_count}</div>
<div className="text-xs text-muted-foreground">Resolved</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold">{latestSummary.mttr_minutes != null ? `${latestSummary.mttr_minutes}m` : '—'}</div>
<div className="text-xs text-muted-foreground">Avg MTTR</div>
</div>
</div>
{/* Delivery results */}
{Object.keys(latestSummary.delivery_status).length > 0 && (
<div className="pt-2 border-t space-y-1">
<p className="text-xs text-muted-foreground font-medium">Delivery</p>
{Object.entries(latestSummary.delivery_status).map(([wid, r]) => {
const webhook = webhooks.find(w => w.id === parseInt(wid));
return (
<div key={wid} className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{webhook?.label ?? `Webhook #${wid}`}</span>
{r.success
? <span className="text-green-500 flex items-center gap-1"><CheckCircle2 className="h-3 w-3" /> Delivered</span>
: <span className="text-red-500 flex items-center gap-1"><XCircle className="h-3 w-3" /> {r.error ?? `HTTP ${r.httpStatus}`}</span>
}
</div>
);
})}
</div>
)}
{/* Card preview toggle */}
{latestSummary.card_payload && (
<div className="pt-2 border-t">
<button
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setCardExpanded(v => !v)}
>
{cardExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
{cardExpanded ? 'Hide' : 'Show'} card payload
</button>
{cardExpanded && (
<pre className="mt-2 text-xs bg-muted/30 rounded p-3 overflow-auto max-h-64 text-muted-foreground">
{JSON.stringify(latestSummary.card_payload, null, 2)}
</pre>
)}
</div>
)}
</div>
)}
{/* Webhooks */}
<div className="rounded-lg border bg-card p-4 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Webhooks</h2>
<Button variant="outline" size="sm" onClick={() => setShowAddWebhook(v => !v)}>
<Plus className="h-3 w-3 mr-1" /> Add
</Button>
</div>
{showAddWebhook && (
<div className="rounded-md border border-dashed p-3 space-y-2 bg-muted/10">
<input
className="w-full text-sm bg-background border rounded px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="Label (e.g. Technical / On-Call)"
value={newLabel}
onChange={e => setNewLabel(e.target.value)}
/>
<input
className="w-full text-sm bg-background border rounded px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-ring font-mono"
placeholder="Webhook URL"
value={newUrl}
onChange={e => setNewUrl(e.target.value)}
/>
<div className="flex gap-2">
<Button size="sm" onClick={handleAddWebhook} disabled={addingWebhook || !newLabel || !newUrl}>
{addingWebhook ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Add Webhook'}
</Button>
<Button size="sm" variant="ghost" onClick={() => { setShowAddWebhook(false); setNewLabel(''); setNewUrl(''); }}>Cancel</Button>
</div>
</div>
)}
{webhooks.length === 0 && !showAddWebhook && (
<p className="text-sm text-muted-foreground text-center py-4">No webhooks configured. Add one above.</p>
)}
<div className="space-y-2">
{webhooks.map(webhook => (
<div key={webhook.id} className={`flex items-center justify-between rounded-md border px-3 py-2.5 ${webhook.enabled ? 'bg-background' : 'bg-muted/20 opacity-60'}`}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{webhook.label}</span>
{webhook.enabled
? <span className="text-xs px-1.5 py-0.5 bg-green-500/10 text-green-400 rounded">Enabled</span>
: <span className="text-xs px-1.5 py-0.5 bg-muted/40 text-muted-foreground rounded">Disabled</span>
}
</div>
<div className="flex items-center gap-3 mt-0.5">
<StatusBadge status={webhook.last_status} />
{webhook.last_delivered_at && (
<span className="text-xs text-muted-foreground">{fmtDate(webhook.last_delivered_at)}</span>
)}
</div>
</div>
<div className="flex items-center gap-1 ml-2">
<Button
size="sm" variant="ghost"
className="h-7 px-2 text-xs"
disabled={testingId === webhook.id}
onClick={() => handleTest(webhook.id, webhook.label)}
>
{testingId === webhook.id ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Test'}
</Button>
<button
className="p-1.5 rounded hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground"
onClick={() => handleToggleWebhook(webhook)}
title={webhook.enabled ? 'Disable' : 'Enable'}
>
{webhook.enabled
? <ToggleRight className="h-4 w-4 text-green-500" />
: <ToggleLeft className="h-4 w-4" />
}
</button>
<button
className="p-1.5 rounded hover:bg-red-500/10 transition-colors text-muted-foreground hover:text-red-500"
onClick={() => handleDeleteWebhook(webhook.id)}
title="Delete"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
</div>
{/* Schedule Config */}
{config && (
<div className="rounded-lg border bg-card p-4 space-y-4">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Schedule Settings</h2>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Weekend Suppression</p>
<p className="text-xs text-muted-foreground">Skip Saturday & Sunday (cron already limits to MonFri)</p>
</div>
<button onClick={() => handleConfigToggle('weekend_suppression')} className="text-muted-foreground hover:text-foreground transition-colors">
{config.weekend_suppression
? <ToggleRight className="h-6 w-6 text-green-500" />
: <ToggleLeft className="h-6 w-6" />
}
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Monday Extended Window</p>
<p className="text-xs text-muted-foreground">On Mondays, extend window to cover the full weekend (Fri 6 PM Mon 6:30 AM)</p>
</div>
<button onClick={() => handleConfigToggle('monday_extended_window')} className="text-muted-foreground hover:text-foreground transition-colors">
{config.monday_extended_window
? <ToggleRight className="h-6 w-6 text-green-500" />
: <ToggleLeft className="h-6 w-6" />
}
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Outages Only</p>
<p className="text-xs text-muted-foreground">Only show "Unavailable" problems filter out slow response and other non-outage alerts</p>
</div>
<button onClick={() => handleConfigToggle('outages_only')} className="text-muted-foreground hover:text-foreground transition-colors">
{config.outages_only
? <ToggleRight className="h-6 w-6 text-green-500" />
: <ToggleLeft className="h-6 w-6" />
}
</button>
</div>
</div>
</div>
)}
{/* Run History */}
{history.length > 0 && (
<div className="rounded-lg border bg-card p-4 space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Recent Runs</h2>
<div className="space-y-1">
{history.map(row => {
const statusEntries = Object.values(row.delivery_status);
const allOk = statusEntries.length > 0 && statusEntries.every((r: any) => r.success);
const anyFail = statusEntries.some((r: any) => !r.success);
return (
<div key={row.id} className="flex items-center justify-between text-sm py-1.5 border-b last:border-0">
<div className="flex items-center gap-3">
{allOk && <CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />}
{anyFail && <AlertTriangle className="h-3.5 w-3.5 text-yellow-500 shrink-0" />}
{statusEntries.length === 0 && <Clock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
<span className="text-muted-foreground">{fmtDate(row.generated_at)}</span>
{row.is_weekend_window && <span className="text-xs px-1.5 py-0.5 bg-blue-500/10 text-blue-400 rounded">Weekend</span>}
</div>
<div className="flex items-center gap-4">
<span className={row.open_count > 0 ? 'text-red-400' : 'text-muted-foreground'}>
{row.open_count} open
</span>
<span className={row.resolved_count > 0 ? 'text-green-400' : 'text-muted-foreground'}>
{row.resolved_count} resolved
</span>
{row.mttr_minutes != null && (
<span className="text-muted-foreground">{row.mttr_minutes}m MTTR</span>
)}
</div>
</div>
);
})}
</div>
</div>
)}
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
</div>
</>
);
}