- dashboard/page.tsx: thread tz into PageHeader description's toLocaleDateString call. - quotes/page.tsx: thread tz into formatDate arrow helper inside the default export. - veeam-analysis/page.tsx: thread tz into the summary footer's generated-at toLocaleString call. Migrates 3 of 81 audit leak callsites.
723 lines
28 KiB
TypeScript
723 lines
28 KiB
TypeScript
'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 DataTable, { type Column } from '@/components/admin/DataTable';
|
||
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';
|
||
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
|
||
|
||
// ── 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`;
|
||
}
|
||
|
||
// ── Column defs + sub-row renderer ────────────────────────────────────────────
|
||
|
||
const TICKET_COLUMNS: Column<TicketRow>[] = [
|
||
{
|
||
key: 'ticket_number',
|
||
label: 'Ticket',
|
||
render: (v) => <span className="num font-medium">{v}</span>,
|
||
},
|
||
{
|
||
key: 'company_name',
|
||
label: 'Client',
|
||
render: (v) => <span className="max-w-[140px] truncate inline-block">{v ?? '—'}</span>,
|
||
},
|
||
{
|
||
key: 'device_hostname',
|
||
label: 'Device',
|
||
render: (v) => <span className="num text-[11px]">{v ?? '—'}</span>,
|
||
},
|
||
{
|
||
key: 'problem_category',
|
||
label: 'Category',
|
||
render: (v) => {
|
||
const cfg = CATEGORY_CFG[v as string];
|
||
return (
|
||
<Badge
|
||
variant="outline"
|
||
style={{ borderColor: cfg?.color, color: cfg?.color }}
|
||
className="text-[10px] h-4 px-1.5 whitespace-nowrap"
|
||
>
|
||
{catLabel(v as string)}
|
||
</Badge>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
key: 'resolution_type',
|
||
label: 'Resolution',
|
||
render: (v) => <span className="text-muted-foreground">{resLabel(v as string)}</span>,
|
||
},
|
||
{
|
||
key: 'same_day_close',
|
||
label: 'Same-day',
|
||
render: (v) =>
|
||
v ? (
|
||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
|
||
) : (
|
||
<span className="text-muted-foreground">—</span>
|
||
),
|
||
},
|
||
{
|
||
key: 'hours_worked',
|
||
label: 'Hours',
|
||
render: (v) => <span className="num">{parseFloat(v as string).toFixed(2)}h</span>,
|
||
},
|
||
{
|
||
key: 'complexity',
|
||
label: 'Complexity',
|
||
render: (v) => (
|
||
<Badge
|
||
variant="outline"
|
||
style={{
|
||
borderColor: COMPLEXITY_COLOR[v as string] ?? '#6b7280',
|
||
color: COMPLEXITY_COLOR[v as string] ?? '#6b7280',
|
||
}}
|
||
className="text-[10px] h-4 px-1.5"
|
||
>
|
||
{v}
|
||
</Badge>
|
||
),
|
||
},
|
||
{
|
||
key: 'ticket_created_at',
|
||
label: 'Age',
|
||
render: (v) => <span className="text-muted-foreground num">{timeAgo(v as string)}</span>,
|
||
},
|
||
];
|
||
|
||
function renderTicketSubRow(t: TicketRow) {
|
||
return (
|
||
<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-emerald-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>
|
||
);
|
||
}
|
||
|
||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||
|
||
export default function VeeamAnalysisPage() {
|
||
const tz = useUserTimezone();
|
||
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 & 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(undefined, { timeZone: tz })}
|
||
</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="px-0 pb-3">
|
||
<DataTable<TicketRow>
|
||
columns={TICKET_COLUMNS}
|
||
data={data.tickets}
|
||
totalCount={data.total}
|
||
page={page}
|
||
pageSize={data.limit ?? 50}
|
||
onPageChange={(next) => {
|
||
setPage(next);
|
||
fetchData(catFilter, next);
|
||
}}
|
||
emptyTitle="No analyzed tickets yet"
|
||
emptyDescription="Run the analysis above to populate this list."
|
||
getRowCanExpand={() => true}
|
||
renderSubRow={renderTicketSubRow}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|