wulf-pulse/app/analyzer/ticket/[ticketNumber]/page.tsx
lorentz 8f8b5ab7be feat: AI ticket analyzer (phases 1-6)
Multi-stage LLM pipeline that produces structured analyses of Autotask
tickets from local Postgres. Migration 069 + Zod schemas, Stage 0
preprocessor, IT Glue redaction + search, Anthropic SDK wrapper, Stages
1/3/4 (Haiku/Sonnet/Opus), pipeline + cost circuit breaker, job worker
(opt-in autostart), 6 API routes, 3 frontend pages, share-row
persistence (email send deferred to phase 7). 128 vitest tests, tsc
clean. Build journal in docs/wulf-pulse-ticket-analyzer-build-notes.md.

Sync: adds syncTicketNotes() + ticket_notes to ordered/date-filtered
entities so the analyzer's local mirror stays current via scheduler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:59:40 -04:00

130 lines
4.7 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 { useEffect, useState, use } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { AnalyzeButton } from '@/components/analyzer/analyze-button';
import { Sparkles } from 'lucide-react';
import type { PersistedAnalysis } from '@/lib/types/analyzer';
export default function TicketAnalyzerPage({
params,
}: {
params: Promise<{ ticketNumber: string }>;
}) {
const { ticketNumber } = use(params);
const [analyses, setAnalyses] = useState<PersistedAnalysis[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(
`/api/analyzer/tickets/${encodeURIComponent(ticketNumber)}/analyses`
);
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? `Request failed: ${res.status}`);
}
const { analyses } = (await res.json()) as { analyses: PersistedAnalysis[] };
if (!cancelled) setAnalyses(analyses);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : 'Unknown error');
}
})();
return () => {
cancelled = true;
};
}, [ticketNumber]);
const latest = analyses?.[0];
return (
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-6">
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Ticket</p>
<CardTitle className="font-mono">{ticketNumber}</CardTitle>
</div>
<AnalyzeButton ticketNumber={ticketNumber} />
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Click <strong>Analyze</strong> to run the AI pipeline. If a current
analysis already exists, you&rsquo;ll be navigated straight to it.
Otherwise the run takes ~1060 seconds.
</p>
</CardContent>
</Card>
{error && (
<Alert variant="destructive">
<AlertTitle>Couldn&rsquo;t load analysis history</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Analysis history</CardTitle>
</CardHeader>
<CardContent>
{analyses === null && !error ? (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : analyses && analyses.length === 0 ? (
<p className="text-sm text-muted-foreground">
No analyses yet. Run one above.
</p>
) : (
<ul className="divide-y">
{(analyses ?? []).map((a) => (
<li key={a.id} className="py-3 flex items-center justify-between gap-4">
<div className="min-w-0">
<Link
href={`/analyzer/analysis/${a.id}`}
className="font-medium hover:underline flex items-center gap-2"
>
<Sparkles className="w-4 h-4" />
Version {a.analysisVersion}
{latest?.id === a.id && (
<Badge variant="secondary" className="text-xs">
latest
</Badge>
)}
{a.needsHumanReview && (
<Badge variant="destructive" className="text-xs">
Needs review
</Badge>
)}
</Link>
<p className="text-xs text-muted-foreground mt-1">
{new Date(a.triggeredAt).toLocaleString()}
{' · '}
{a.opusUsed ? 'Haiku → Sonnet → Opus' : a.sonnetUsed ? 'Haiku → Sonnet' : 'Haiku'}
{' · '}${a.estimatedCostUsd.toFixed(4)}
</p>
</div>
{a.confidenceScore !== null && (
<Badge variant="outline">
{Math.round(a.confidenceScore * 100)}%
</Badge>
)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}