wulf-pulse/app/admin/ticket-digest/page.tsx

456 lines
19 KiB
TypeScript
Raw Normal View History

'use client';
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import {
Send, RefreshCw, CheckCircle2, XCircle,
Clock, Loader2, ChevronDown, ChevronUp, BarChart3, Brain,
Calendar, CalendarDays, CalendarRange, MessageSquare, Bell, Globe, ExternalLink,
} 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 DigestConfig {
daily_enabled: boolean;
weekly_enabled: boolean;
monthly_enabled: boolean;
daily_cron: string;
weekly_cron: string;
monthly_cron: string;
llm_provider: string;
llm_model: string;
include_noise_analysis: boolean;
include_sla_analysis: boolean;
include_resource_analysis: boolean;
include_client_analysis: boolean;
include_recommendations: boolean;
channel_ids: number[];
}
interface NotificationChannel {
id: number;
name: string;
channel_type: 'teams' | 'telegram' | 'ntfy' | 'webhook';
config: Record<string, any>;
is_active: boolean;
}
interface DigestReport {
id: number;
period_type: string;
period_start: string;
period_end: string;
generated_at: string;
stats: any;
llm_analysis: string | null;
delivery_status: Record<string, { success: boolean; httpStatus?: number; error?: string }>;
tokens_used: number | null;
processing_time_ms: number | null;
}
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 ChannelIcon({ type }: { type: string }) {
if (type === 'teams') return <MessageSquare className="h-4 w-4 text-indigo-500" />;
if (type === 'telegram') return <Send className="h-4 w-4 text-blue-500" />;
if (type === 'ntfy') return <Bell className="h-4 w-4 text-green-500" />;
return <Globe className="h-4 w-4 text-gray-500" />;
}
function PeriodIcon({ period }: { period: string }) {
if (period === 'daily') return <Calendar className="h-4 w-4 text-blue-500" />;
if (period === 'weekly') return <CalendarDays className="h-4 w-4 text-purple-500" />;
return <CalendarRange className="h-4 w-4 text-orange-500" />;
}
export default function TicketDigestPage() {
const [config, setConfig] = useState<DigestConfig | null>(null);
const [channels, setChannels] = useState<NotificationChannel[]>([]);
const [history, setHistory] = useState<DigestReport[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState<string | null>(null);
const [toast, setToast] = useState<{ msg: string; ok: boolean } | null>(null);
const [expandedReport, setExpandedReport] = useState<number | null>(null);
const [previewData, setPreviewData] = useState<any>(null);
const [previewPeriod, setPreviewPeriod] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const showToast = useCallback((msg: string, ok: boolean) => {
setToast({ msg, ok });
setTimeout(() => setToast(null), 4000);
}, []);
const loadData = useCallback(async () => {
try {
const res = await fetch('/api/reports/ticket-digest');
const data = await res.json();
setConfig(data.config);
setChannels(data.channels || []);
setHistory(data.history || []);
} catch (e) {
showToast('Failed to load data', false);
} finally {
setLoading(false);
}
}, [showToast]);
useEffect(() => { loadData(); }, [loadData]);
const generateReport = async (period: string) => {
setGenerating(period);
try {
const res = await fetch('/api/reports/ticket-digest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ period }),
});
const data = await res.json();
if (data.success) {
showToast(`${period} digest generated (${data.processingTimeMs}ms)`, true);
loadData();
} else {
showToast(data.error || 'Generation failed', false);
}
} catch (e) {
showToast('Network error', false);
} finally {
setGenerating(null);
}
};
const loadPreview = async (period: string) => {
if (previewPeriod === period) { setPreviewPeriod(null); setPreviewData(null); return; }
setPreviewLoading(true);
setPreviewPeriod(period);
try {
const res = await fetch(`/api/reports/ticket-digest?preview=${period}`);
const data = await res.json();
setPreviewData(data.stats);
} catch {
showToast('Failed to load preview', false);
} finally {
setPreviewLoading(false);
}
};
const updateConfig = async (updates: Partial<DigestConfig>) => {
try {
const res = await fetch('/api/reports/ticket-digest/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
const data = await res.json();
setConfig(data.config);
showToast('Config updated', true);
} catch {
showToast('Failed to update config', false);
}
};
const toggleChannelId = async (id: number) => {
if (!config) return;
const current = config.channel_ids || [];
const updated = current.includes(id)
? current.filter(c => c !== id)
: [...current, id];
await updateConfig({ channel_ids: updated });
};
if (loading) return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="h-8 w-8 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="Ticket Digest Reports"
description="LLM-analyzed ticket reports delivered to Teams — daily, weekly, and monthly"
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Ticket Digest Reports' }]}
accent
actions={
<Button variant="outline" size="sm" onClick={loadData}>
<RefreshCw className="h-4 w-4 mr-1" /> Refresh
</Button>
}
/>
<div className="container mx-auto py-8 px-4 max-w-4xl space-y-8">
{/* Toast */}
{toast && (
<div className={`fixed top-4 right-4 z-50 px-4 py-2 rounded-lg shadow-lg text-sm text-white ${toast.ok ? 'bg-green-600' : 'bg-red-600'}`}>
{toast.msg}
</div>
)}
{/* Generate Reports */}
<div className="border rounded-lg p-5 space-y-4">
<h2 className="font-semibold text-lg flex items-center gap-2">
<Brain className="h-5 w-5" /> Generate Report
</h2>
<div className="grid grid-cols-3 gap-3">
{(['daily', 'weekly', 'monthly'] as const).map(p => (
<div key={p} className="border rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2">
<PeriodIcon period={p} />
<span className="font-medium capitalize">{p}</span>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => generateReport(p)}
disabled={!!generating}
className="flex-1"
>
{generating === p ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Send className="h-4 w-4 mr-1" />}
Generate & Send
</Button>
<Button
size="sm"
variant="outline"
onClick={() => loadPreview(p)}
disabled={previewLoading}
>
<BarChart3 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
{/* Preview */}
{previewPeriod && previewData && (
<div className="border rounded-lg p-4 bg-muted/30 space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold capitalize">{previewPeriod} Preview {previewData.period?.label}</h3>
<Button size="sm" variant="ghost" onClick={() => { setPreviewPeriod(null); setPreviewData(null); }}>
<XCircle className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-4 gap-3 text-center">
<div className="bg-background rounded p-2">
<div className="text-2xl font-bold">{previewData.overview?.total_created ?? 0}</div>
<div className="text-xs text-muted-foreground">Created</div>
</div>
<div className="bg-background rounded p-2">
<div className="text-2xl font-bold">{previewData.overview?.total_resolved ?? 0}</div>
<div className="text-xs text-muted-foreground">Resolved</div>
</div>
<div className="bg-background rounded p-2">
<div className="text-2xl font-bold">{previewData.overview?.avg_resolution_hours ?? '—'}h</div>
<div className="text-xs text-muted-foreground">Avg Resolve</div>
</div>
<div className="bg-background rounded p-2">
<div className="text-2xl font-bold">{(previewData.overview?.total_hours_worked ?? 0).toFixed(1)}h</div>
<div className="text-xs text-muted-foreground">Hours Worked</div>
</div>
</div>
{previewData.noise_candidates?.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-1">🔁 Noise Candidates ({previewData.noise_candidates.length})</h4>
<div className="text-xs space-y-1 max-h-40 overflow-y-auto">
{previewData.noise_candidates.slice(0, 10).map((n: any, i: number) => (
<div key={i} className="flex justify-between bg-background rounded px-2 py-1">
<span className="truncate">{n.title}</span>
<span className="text-muted-foreground shrink-0 ml-2">{n.count}× · {n.source_label}</span>
</div>
))}
</div>
</div>
)}
{previewData.top_clients?.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-1">🏢 Top Clients</h4>
<div className="text-xs space-y-1">
{previewData.top_clients.slice(0, 5).map((c: any, i: number) => (
<div key={i} className="flex justify-between bg-background rounded px-2 py-1">
<span>{c.company_name}</span>
<span className="text-muted-foreground">{c.ticket_count} tickets · {c.hours_worked.toFixed(1)}h</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* Analysis Sections Config */}
{config && (
<div className="border rounded-lg p-5 space-y-4">
<h2 className="font-semibold text-lg">Analysis Sections</h2>
<div className="grid grid-cols-2 gap-3">
{([
{ key: 'include_noise_analysis', label: '🔁 Noise & Automation' },
{ key: 'include_sla_analysis', label: '⏱️ SLA & Response Times' },
{ key: 'include_resource_analysis', label: '👥 Team Workload' },
{ key: 'include_client_analysis', label: '🏢 Client Spotlight' },
{ key: 'include_recommendations', label: '💡 Recommendations' },
] as const).map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 cursor-pointer p-2 rounded hover:bg-muted/50">
<input
type="checkbox"
checked={(config as any)[key]}
onChange={() => updateConfig({ [key]: !(config as any)[key] })}
className="rounded"
/>
<span className="text-sm">{label}</span>
</label>
))}
</div>
<div className="flex items-center gap-4 text-sm">
<label className="flex items-center gap-2">
Provider:
<select
value={config.llm_provider}
onChange={e => updateConfig({ llm_provider: e.target.value })}
className="border rounded px-2 py-1 bg-background"
>
<option value="anthropic">Anthropic</option>
<option value="openai">OpenAI</option>
</select>
</label>
<label className="flex items-center gap-2">
Model:
<input
value={config.llm_model}
onChange={e => updateConfig({ llm_model: e.target.value })}
className="border rounded px-2 py-1 bg-background w-56"
/>
</label>
</div>
</div>
)}
{/* Notification Channels */}
<div className="border rounded-lg p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold text-lg">Delivery Channels</h2>
<p className="text-xs text-muted-foreground mt-0.5">Select which notification channels receive these reports. Manage channels in <a href="/admin/workflow/channels" className="underline">Notification Channels</a>.</p>
</div>
<a href="/admin/workflow/channels" className="text-xs text-muted-foreground flex items-center gap-1 hover:text-foreground">
<ExternalLink className="h-3 w-3" /> Manage Channels
</a>
</div>
{channels.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No notification channels configured yet.{' '}
<a href="/admin/workflow/channels" className="underline">Add one here.</a>
</p>
) : (
<div className="space-y-2">
{channels.map(ch => {
const selected = config?.channel_ids?.includes(ch.id) ?? false;
return (
<label
key={ch.id}
className={`flex items-center gap-3 border rounded-lg p-3 cursor-pointer transition-colors ${
selected ? 'border-primary bg-primary/5' : 'hover:bg-muted/30'
} ${!ch.is_active ? 'opacity-50' : ''}`}
>
<input
type="checkbox"
checked={selected}
onChange={() => toggleChannelId(ch.id)}
className="rounded"
disabled={!ch.is_active}
/>
<ChannelIcon type={ch.channel_type} />
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{ch.name}</div>
<div className="text-xs text-muted-foreground capitalize">{ch.channel_type}{!ch.is_active ? ' · Inactive' : ''}</div>
</div>
{selected && (
<span className="text-xs text-primary font-medium">Selected</span>
)}
</label>
);
})}
</div>
)}
{config && (config.channel_ids?.length ?? 0) === 0 && channels.length > 0 && (
<p className="text-xs text-amber-500"> No channels selected reports will be generated but not delivered.</p>
)}
</div>
{/* History */}
<div className="border rounded-lg p-5 space-y-4">
<h2 className="font-semibold text-lg">Report History</h2>
{history.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No reports generated yet. Generate your first report above.</p>
) : (
<div className="space-y-2">
{history.map(report => {
const isExpanded = expandedReport === report.id;
const ov = report.stats?.overview;
return (
<div key={report.id} className="border rounded-lg overflow-hidden">
<button
className="w-full flex items-center justify-between p-3 hover:bg-muted/30 text-left"
onClick={() => setExpandedReport(isExpanded ? null : report.id)}
>
<div className="flex items-center gap-3">
<PeriodIcon period={report.period_type} />
<div>
<span className="font-medium text-sm capitalize">{report.period_type}</span>
<span className="text-xs text-muted-foreground ml-2">
{new Date(report.generated_at).toLocaleString()}
</span>
</div>
</div>
<div className="flex items-center gap-4 text-xs">
{ov && (
<span className="text-muted-foreground">
{ov.total_created} created · {ov.total_resolved} resolved
</span>
)}
{report.tokens_used && (
<span className="text-muted-foreground">{report.tokens_used} tokens</span>
)}
{report.processing_time_ms && (
<span className="text-muted-foreground">{(report.processing_time_ms / 1000).toFixed(1)}s</span>
)}
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</div>
</button>
{isExpanded && report.llm_analysis && (
<div className="px-4 pb-4 border-t">
<div className="mt-3 prose prose-sm max-w-none dark:prose-invert text-sm whitespace-pre-wrap">
{report.llm_analysis}
</div>
{report.delivery_status && Object.keys(report.delivery_status).length > 0 && (
<div className="mt-3 border-t pt-2">
<span className="text-xs font-medium text-muted-foreground">Delivery:</span>
{Object.entries(report.delivery_status).map(([whId, st]) => (
<span key={whId} className={`text-xs ml-2 ${(st as any).success ? 'text-green-500' : 'text-red-500'}`}>
#{whId}: {(st as any).success ? 'OK' : (st as any).error || 'Failed'}
</span>
))}
</div>
)}
</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>
</>
);
}