wulf-pulse/app/veeam-analysis/page.tsx
lorentz 9bfb57553d 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

739 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
} from 'recharts';
import {
Brain, Play, RefreshCw, CheckCircle2, AlertTriangle, Clock,
WifiOff, ChevronDown, ChevronRight, Wrench, RotateCcw, Sparkles, Zap, BookOpen, GraduationCap,
} from 'lucide-react';
// ── Types ─────────────────────────────────────────────────────────────────────
interface Stats {
total_analyzed: string;
total_ytd: string;
avg_hours: string;
same_day_pct: string;
preventable_pct: string;
offline_pct: string;
auto_resolved_pct: string;
}
interface CategoryRow { problem_category: string; count: string; avg_hours: string; same_day_pct: string }
interface ResolutionRow { resolution_type: string; count: string }
interface SkillRow { skill: string; count: string }
interface ComplexityRow { complexity: string; count: string }
interface AnalysisData {
stats: Stats;
by_category: CategoryRow[];
by_resolution: ResolutionRow[];
skills: SkillRow[];
by_complexity: ComplexityRow[];
tickets: TicketRow[];
total: number;
limit: number;
page: number;
}
interface TicketRow {
ticket_number: string;
company_name: string | null;
device_hostname: string | null;
ticket_created_at: string | null;
ticket_closed_at: string | null;
same_day_close: boolean;
hours_worked: string;
problem_category: string;
resolution_type: string;
complexity: string;
device_was_offline: boolean | null;
backup_completed_before_tech: boolean | null;
preventable: boolean | null;
work_summary: string | null;
recommended_procedure: string | null;
}
interface RunStatus {
is_running: boolean;
run_total: number;
run_done: number;
run_errors: number;
total_analyzed: number;
total_eligible: number;
last_analyzed_at: string | null;
}
// ── Config ────────────────────────────────────────────────────────────────────
const CATEGORY_CFG: Record<string, { label: string; color: string }> = {
device_offline: { label: 'Device Offline', color: '#94a3b8' },
agent_issue: { label: 'Agent Issue', color: '#f97316' },
job_failed: { label: 'Job Failed', color: '#ef4444' },
storage_issue: { label: 'Storage Issue', color: '#f59e0b' },
network_issue: { label: 'Network Issue', color: '#3b82f6' },
authentication: { label: 'Authentication', color: '#8b5cf6' },
software_error: { label: 'Software Error', color: '#ec4899' },
self_resolved: { label: 'Self-Resolved', color: '#22c55e' },
configuration: { label: 'Configuration', color: '#06b6d4' },
other: { label: 'Other', color: '#6b7280' },
};
const RESOLUTION_CFG: Record<string, string> = {
no_action_needed: 'No Action Needed',
device_powered_on: 'Device Powered On',
backup_restarted: 'Backup Restarted',
agent_reinstalled: 'Agent Reinstalled',
storage_cleared: 'Storage Cleared',
settings_updated: 'Settings Updated',
escalated: 'Escalated',
other: 'Other',
};
const COMPLEXITY_COLOR: Record<string, string> = {
trivial: '#22c55e',
low: '#84cc16',
medium: '#f59e0b',
high: '#ef4444',
};
interface SummaryAnalysis {
headline: string;
key_findings: string[];
issue_breakdown: { category: string; insight: string; sop: string }[];
skills_assessment: string;
quick_wins: string[];
automation_opportunities: string;
training_priority: string;
}
interface SummaryData {
analysis: SummaryAnalysis;
model: string;
generated_at: string;
}
function catLabel(k: string) { return CATEGORY_CFG[k]?.label ?? k; }
function catColor(k: string) { return CATEGORY_CFG[k]?.color ?? '#6b7280'; }
function resLabel(k: string) { return RESOLUTION_CFG[k] ?? k; }
function timeAgo(d: string | null) {
if (!d) return '—';
const h = Math.floor((Date.now() - new Date(d).getTime()) / 3_600_000);
if (h < 1) return 'Just now';
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
// ── Expandable ticket row ─────────────────────────────────────────────────────
function TicketRow({ t, categoryFilter, onFilter }: {
t: TicketRow;
categoryFilter: string;
onFilter: (cat: string) => void;
}) {
const [open, setOpen] = useState(false);
const catCfg = CATEGORY_CFG[t.problem_category];
return (
<>
<TableRow
className="cursor-pointer text-xs align-middle"
onClick={() => setOpen(o => !o)}
>
<TableCell className="w-6 pl-3">
{open
? <ChevronDown className="h-3 w-3 text-muted-foreground" />
: <ChevronRight className="h-3 w-3 text-muted-foreground" />}
</TableCell>
<TableCell className="num font-medium">{t.ticket_number}</TableCell>
<TableCell className="max-w-[140px] truncate">{t.company_name ?? '—'}</TableCell>
<TableCell className="num text-[11px]">{t.device_hostname ?? '—'}</TableCell>
<TableCell>
<Badge
variant="outline"
style={{ borderColor: catCfg?.color, color: catCfg?.color }}
className="text-[10px] h-4 px-1.5 whitespace-nowrap"
>
{catLabel(t.problem_category)}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{resLabel(t.resolution_type)}</TableCell>
<TableCell className="text-center">
{t.same_day_close
? <CheckCircle2 className="h-3.5 w-3.5 text-emerald-500 mx-auto" />
: <span className="text-muted-foreground"></span>}
</TableCell>
<TableCell className="text-right num">{parseFloat(t.hours_worked).toFixed(2)}h</TableCell>
<TableCell>
<Badge
variant="outline"
style={{ borderColor: COMPLEXITY_COLOR[t.complexity] ?? '#6b7280', color: COMPLEXITY_COLOR[t.complexity] ?? '#6b7280' }}
className="text-[10px] h-4 px-1.5"
>
{t.complexity}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground num">{timeAgo(t.ticket_created_at)}</TableCell>
</TableRow>
{open && (
<TableRow className="bg-muted/5">
<TableCell colSpan={10} className="px-8 pb-3 pt-2">
<div className="grid grid-cols-2 gap-4 text-xs">
<div className="space-y-1.5">
{t.work_summary && (
<div>
<span className="text-muted-foreground uppercase tracking-wide text-[10px] font-medium">Summary</span>
<p className="mt-0.5">{t.work_summary}</p>
</div>
)}
<div className="flex flex-wrap gap-3 text-muted-foreground">
{t.device_was_offline != null && (
<span className="flex items-center gap-1">
<WifiOff className="h-3 w-3" />
{t.device_was_offline ? 'Device was offline' : 'Device was online'}
</span>
)}
{t.backup_completed_before_tech != null && (
<span className="flex items-center gap-1">
{t.backup_completed_before_tech
? <><CheckCircle2 className="h-3 w-3 text-green-500" /> Backup auto-completed</>
: <><Wrench className="h-3 w-3" /> Tech action required</>}
</span>
)}
{t.preventable != null && (
<span className={t.preventable ? 'text-amber-500' : ''}>
{t.preventable ? '⚠ Preventable' : '✓ Not preventable'}
</span>
)}
</div>
</div>
{t.recommended_procedure && (
<div>
<span className="text-muted-foreground uppercase tracking-wide text-[10px] font-medium">Recommended SOP</span>
<p className="mt-0.5 text-muted-foreground italic">{t.recommended_procedure}</p>
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function VeeamAnalysisPage() {
const [data, setData] = useState<AnalysisData | null>(null);
const [status, setStatus] = useState<RunStatus | null>(null);
const [loading, setLoading] = useState(true);
const [catFilter, setCatFilter] = useState('');
const [page, setPage] = useState(1);
const [summary, setSummary] = useState<SummaryData | null>(null);
const [summaryLoading, setSummaryLoading] = useState(false);
const [summaryError, setSummaryError] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchData = useCallback(async (cat = catFilter, p = page) => {
const params = new URLSearchParams({ page: String(p) });
if (cat) params.set('category', cat);
const res = await fetch(`/api/veeam/ticket-analysis?${params}`);
setData(await res.json());
setLoading(false);
}, [catFilter, page]);
const fetchStatus = useCallback(async () => {
const res = await fetch('/api/veeam/ticket-analysis/status');
const s: RunStatus = await res.json();
setStatus(s);
return s;
}, []);
useEffect(() => { fetchData(); fetchStatus(); }, []);
const startPolling = useCallback(() => {
if (pollRef.current) return;
pollRef.current = setInterval(async () => {
const s = await fetchStatus();
if (!s.is_running) {
clearInterval(pollRef.current!);
pollRef.current = null;
fetchData();
}
}, 2000);
}, [fetchStatus, fetchData]);
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); }, []);
const handleRun = async (reanalyze = false) => {
const res = await fetch('/api/veeam/ticket-analysis/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reanalyze }),
});
const json = await res.json();
if (json.started) {
await fetchStatus();
startPolling();
}
};
const handleSummary = async () => {
setSummaryLoading(true);
setSummaryError(null);
try {
const res = await fetch('/api/veeam/ticket-analysis/summary', { method: 'POST' });
const json = await res.json();
if (json.error) setSummaryError(json.raw ? `${json.error}\n\n${json.raw}` : json.error);
else setSummary(json);
} catch (e: any) {
setSummaryError(e.message);
} finally {
setSummaryLoading(false);
}
};
const handleCatFilter = (cat: string) => {
const next = catFilter === cat ? '' : cat;
setCatFilter(next);
setPage(1);
setLoading(true);
fetchData(next, 1);
};
const totalPages = data ? Math.ceil(data.total / (data.limit ?? 50)) : 1;
const runPct = status?.is_running && status.run_total > 0
? Math.round(100 * status.run_done / status.run_total)
: null;
return (
<div className="container mx-auto px-6 py-6 space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Brain className="h-6 w-6" />
Veeam Backup Ticket Analysis
</h1>
<p className="text-sm text-muted-foreground mt-1">
AI classification of YTD Veeam tickets to identify failure patterns, required skills, and SOP gaps.
</p>
</div>
<div className="flex items-center gap-2">
{status && !status.is_running && status.total_analyzed > 0 && (
<Button variant="outline" size="sm" onClick={() => handleRun(true)}>
<RotateCcw className="h-3.5 w-3.5 mr-1" />
Re-analyze all
</Button>
)}
<Button
size="sm"
onClick={() => handleRun(false)}
disabled={status?.is_running}
>
{status?.is_running
? <><RefreshCw className="h-3.5 w-3.5 mr-1 animate-spin" />Running</>
: <><Play className="h-3.5 w-3.5 mr-1" />
{status && status.total_analyzed < status.total_eligible ? 'Continue Analysis' : 'Run Analysis'}
</>}
</Button>
</div>
</div>
{/* Progress bar */}
{status?.is_running && runPct !== null && (
<div className="space-y-1">
<Progress value={runPct} className="h-2" />
<p className="text-xs text-muted-foreground">
{status.run_done} / {status.run_total} tickets analyzed
{status.run_errors > 0 && ` · ${status.run_errors} errors`}
</p>
</div>
)}
{/* Status line */}
{status && !status.is_running && (
<p className="text-xs text-muted-foreground -mt-4">
{status.total_analyzed} of {status.total_eligible} eligible tickets analyzed
{status.last_analyzed_at && ` · Last run ${timeAgo(status.last_analyzed_at)}`}
{status.total_eligible === 0 && ' — no Veeam tickets with time entries found YTD'}
</p>
)}
{status?.total_analyzed === 0 && !status.is_running ? (
<div className="rounded-md border border-dashed py-16 text-center text-muted-foreground">
<Brain className="h-8 w-8 mx-auto mb-3 opacity-30" />
<p className="text-sm">No analysis data yet.</p>
<p className="text-xs mt-1">Click <strong>Run Analysis</strong> to classify {status.total_eligible} tickets with time entries.</p>
</div>
) : (
<>
{/* Summary cards */}
{loading && !data ? (
<div className="grid gap-4 md:grid-cols-4">
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-24" />)}
</div>
) : data && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[
{
label: 'Tickets Analyzed',
value: data.stats.total_analyzed,
sub: `of ${data.stats.total_ytd} YTD`,
icon: Brain,
cls: '',
},
{
label: 'Same-Day Close',
value: `${data.stats.same_day_pct ?? 0}%`,
sub: 'Opened & closed same day',
icon: CheckCircle2,
cls: 'text-green-500',
},
{
label: 'Avg Hours/Ticket',
value: `${data.stats.avg_hours ?? 0}h`,
sub: `${data.stats.auto_resolved_pct ?? 0}% auto-resolved before tech`,
icon: Clock,
cls: '',
},
{
label: 'Preventable',
value: `${data.stats.preventable_pct ?? 0}%`,
sub: `${data.stats.offline_pct ?? 0}% device was offline`,
icon: AlertTriangle,
cls: 'text-amber-500',
},
].map(({ label, value, sub, icon: Icon, cls }) => (
<Card key={label}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{label}</CardTitle>
<Icon className={`h-4 w-4 ${cls || 'text-muted-foreground'}`} />
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${cls}`}>{value}</div>
<p className="text-xs text-muted-foreground">{sub}</p>
</CardContent>
</Card>
))}
</div>
)}
{/* Charts row */}
{data && (
<div className="grid gap-6 lg:grid-cols-2">
{/* Problem categories */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Problem Categories</CardTitle>
<p className="text-xs text-muted-foreground">Click a bar to filter the ticket list</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={260}>
<BarChart
data={data.by_category.map(r => ({
name: catLabel(r.problem_category),
key: r.problem_category,
count: parseInt(r.count),
avg_hours: parseFloat(r.avg_hours),
}))}
layout="vertical"
margin={{ left: 10, right: 20, top: 0, bottom: 0 }}
>
<XAxis type="number" tick={{ fontSize: 10 }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} width={110} />
<Tooltip
contentStyle={{ fontSize: 12 }}
/>
<Bar dataKey="count" radius={[0, 3, 3, 0]} onClick={(d) => handleCatFilter(String(d.key ?? ''))}>
{data.by_category.map((r, i) => (
<Cell
key={i}
fill={catColor(r.problem_category)}
opacity={catFilter && catFilter !== r.problem_category ? 0.3 : 1}
cursor="pointer"
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Right column: Resolution + Skills */}
<div className="space-y-4">
{/* Resolution types */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Resolution Types</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1.5">
{data.by_resolution.map(r => {
const total = data.by_resolution.reduce((s, x) => s + parseInt(x.count), 0);
const pct = total > 0 ? Math.round(100 * parseInt(r.count) / total) : 0;
return (
<div key={r.resolution_type} className="flex items-center gap-2 text-xs">
<span className="w-36 text-muted-foreground truncate">{resLabel(r.resolution_type)}</span>
<div className="flex-1 bg-muted rounded-full h-1.5">
<div className="bg-primary h-1.5 rounded-full" style={{ width: `${pct}%` }} />
</div>
<span className="w-8 text-right font-medium">{r.count}</span>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Skills */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Skills Required</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-1.5">
{data.skills.map(s => (
<Badge key={s.skill} variant="secondary" className="text-[11px]">
{s.skill}
<span className="ml-1.5 text-muted-foreground">{s.count}</span>
</Badge>
))}
</div>
</CardContent>
</Card>
</div>
</div>
)}
{/* Sonnet Summary */}
{data && parseInt(data.stats.total_analyzed) > 0 && (
<Card>
<CardHeader className="pb-3 flex flex-row items-center justify-between">
<div>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Sparkles className="h-4 w-4 text-blue-500" />
Operations Summary
</CardTitle>
<p className="text-xs text-muted-foreground mt-0.5">
Sonnet analysis of aggregate patterns issue types, skills, and SOPs
</p>
</div>
<Button
variant={summary ? 'outline' : 'default'}
size="sm"
onClick={handleSummary}
disabled={summaryLoading}
>
{summaryLoading
? <><RefreshCw className="h-3.5 w-3.5 mr-1.5 animate-spin" />Analyzing</>
: summary
? <><RotateCcw className="h-3.5 w-3.5 mr-1.5" />Refresh</>
: <><Sparkles className="h-3.5 w-3.5 mr-1.5" />Generate Summary</>}
</Button>
</CardHeader>
{summaryError && (
<CardContent>
<p className="text-sm text-destructive">{summaryError}</p>
</CardContent>
)}
{summaryLoading && !summary && (
<CardContent className="space-y-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
<Skeleton className="h-3 w-full" />
</CardContent>
)}
{summary && (
<CardContent className="space-y-6 pt-0">
{/* Headline */}
<p className="text-sm font-medium">{summary.analysis.headline}</p>
{/* Key findings */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
Key Findings
</h3>
<ul className="space-y-1">
{summary.analysis.key_findings?.map((f, i) => (
<li key={i} className="text-sm flex gap-2">
<span className="text-muted-foreground mt-0.5"></span>
<span>{f}</span>
</li>
))}
</ul>
</div>
{/* Issue breakdown */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">
Issue Breakdown &amp; SOPs
</h3>
<div className="space-y-3">
{summary.analysis.issue_breakdown?.map((item, i) => (
<div key={i} className="rounded-md border px-4 py-3 space-y-1">
<div className="flex items-center gap-2">
<Badge
variant="outline"
style={{ borderColor: catColor(item.category), color: catColor(item.category) }}
className="text-[10px] h-4 px-1.5"
>
{catLabel(item.category)}
</Badge>
</div>
<p className="text-sm">{item.insight}</p>
<div className="flex items-start gap-1.5 text-xs text-muted-foreground bg-muted/40 rounded px-2.5 py-1.5">
<BookOpen className="h-3 w-3 mt-0.5 flex-shrink-0" />
<span><span className="font-medium text-foreground">SOP:</span> {item.sop}</span>
</div>
</div>
))}
</div>
</div>
{/* Bottom row: skills + quick wins + automation */}
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
<GraduationCap className="h-3.5 w-3.5" />Skills Assessment
</h3>
<p className="text-sm text-muted-foreground">{summary.analysis.skills_assessment}</p>
{summary.analysis.training_priority && (
<p className="text-xs border-l-2 border-blue-500/50 pl-2 text-muted-foreground italic">
{summary.analysis.training_priority}
</p>
)}
</div>
<div className="space-y-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5" />Quick Wins
</h3>
<ul className="space-y-1">
{summary.analysis.quick_wins?.map((w, i) => (
<li key={i} className="text-sm flex gap-2">
<span className="text-muted-foreground mt-0.5"></span>
<span>{w}</span>
</li>
))}
</ul>
</div>
<div className="space-y-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
<Brain className="h-3.5 w-3.5" />Automation Opportunities
</h3>
<p className="text-sm text-muted-foreground">{summary.analysis.automation_opportunities}</p>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
Generated by {summary.model} · {new Date(summary.generated_at).toLocaleString()}
</p>
</CardContent>
)}
</Card>
)}
{/* Ticket table */}
{data && (
<Card>
<CardHeader className="pb-2 flex flex-row items-center justify-between">
<div>
<CardTitle className="text-sm font-medium">
Tickets
{catFilter && (
<Badge variant="secondary" className="ml-2 text-[10px]">
{catLabel(catFilter)}
<button onClick={() => handleCatFilter(catFilter)} className="ml-1 hover:text-destructive">×</button>
</Badge>
)}
</CardTitle>
<p className="text-xs text-muted-foreground mt-0.5">{data.total} tickets</p>
</div>
<Button variant="ghost" size="sm" onClick={() => fetchData()}>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead className="w-6" />
<TableHead>Ticket</TableHead>
<TableHead>Client</TableHead>
<TableHead>Device</TableHead>
<TableHead>Category</TableHead>
<TableHead>Resolution</TableHead>
<TableHead className="text-center">Same-day</TableHead>
<TableHead className="text-right">Hours</TableHead>
<TableHead>Complexity</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.tickets.length > 0
? data.tickets.map(t => (
<TicketRow
key={t.ticket_number}
t={t}
categoryFilter={catFilter}
onFilter={handleCatFilter}
/>
))
: (
<TableRow>
<TableCell colSpan={10} className="px-4 py-10 text-center text-sm text-muted-foreground">
No analyzed tickets yet run the analysis above.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t text-xs text-muted-foreground">
<span>Page {page} of {totalPages}</span>
<div className="flex gap-2">
<Button
variant="outline" size="sm"
disabled={page <= 1}
onClick={() => { setPage(p => p - 1); fetchData(catFilter, page - 1); }}
>Previous</Button>
<Button
variant="outline" size="sm"
disabled={page >= totalPages}
onClick={() => { setPage(p => p + 1); fetchData(catFilter, page + 1); }}
>Next</Button>
</div>
</div>
)}
</CardContent>
</Card>
)}
</>
)}
</div>
);
}