- /analyzer/tickets — period chips (today/yesterday/this+last week/30d/60d/all), client + issue-type Selects, debounced search, per-row Analyze/Re-analyze plus View shortcut when an analysis already exists. - API: /api/analyzer/tickets/list (period/companyId/issueType/search, paginated via COUNT(*) OVER) and /filter-options (companies that actually have tickets, active issue types). - ProseText helper in analysis-view splits on blank lines and renders each chunk with leading-7 — Summary, Next Step, rationale, and Post-Resolution now have proper paragraph rhythm. Next Step card re-styled with bg-primary/5 tint, ArrowRight icon, and an indented rationale block. - Top-level "Analyzer" nav menu (Browse Tickets + Needs Review). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
487 lines
17 KiB
TypeScript
487 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import {
|
|
Search,
|
|
Sparkles,
|
|
CheckCircle2,
|
|
Filter,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { AnalyzeButton } from '@/components/analyzer/analyze-button';
|
|
|
|
type Period =
|
|
| 'today'
|
|
| 'yesterday'
|
|
| 'this_week'
|
|
| 'last_week'
|
|
| 'last_30d'
|
|
| 'last_60d'
|
|
| 'all';
|
|
|
|
interface PeriodOption {
|
|
value: Period;
|
|
label: string;
|
|
}
|
|
|
|
const PERIOD_OPTIONS: PeriodOption[] = [
|
|
{ value: 'today', label: 'Today' },
|
|
{ value: 'yesterday', label: 'Yesterday' },
|
|
{ value: 'this_week', label: 'This week' },
|
|
{ value: 'last_week', label: 'Last week' },
|
|
{ value: 'last_30d', label: 'Last 30 days' },
|
|
{ value: 'last_60d', label: 'Last 60 days' },
|
|
{ value: 'all', label: 'All time' },
|
|
];
|
|
|
|
interface TicketRow {
|
|
ticketNumber: string;
|
|
title: string | null;
|
|
companyName: string | null;
|
|
issueTypeLabel: string | null;
|
|
statusLabel: string | null;
|
|
priorityLabel: string | null;
|
|
lastActivityDate: string | null;
|
|
createDate: string | null;
|
|
latestAnalysisId: string | null;
|
|
latestAnalysisVersion: number | null;
|
|
}
|
|
|
|
interface FilterOptions {
|
|
companies: { id: string; name: string }[];
|
|
issueTypes: { value: number; label: string }[];
|
|
}
|
|
|
|
const PAGE_SIZE = 50;
|
|
|
|
const ALL_COMPANIES = '__all_companies__';
|
|
const ALL_ISSUE_TYPES = '__all_issue_types__';
|
|
|
|
function formatRelative(iso: string | null): string {
|
|
if (!iso) return '—';
|
|
const d = new Date(iso);
|
|
const diffMs = Date.now() - d.getTime();
|
|
const diffMin = Math.round(diffMs / 60000);
|
|
if (diffMin < 1) return 'just now';
|
|
if (diffMin < 60) return `${diffMin}m ago`;
|
|
const diffHr = Math.round(diffMin / 60);
|
|
if (diffHr < 24) return `${diffHr}h ago`;
|
|
const diffDays = Math.round(diffHr / 24);
|
|
if (diffDays < 7) return `${diffDays}d ago`;
|
|
return d.toLocaleDateString();
|
|
}
|
|
|
|
export default function AnalyzerBrowseTicketsPage() {
|
|
const [period, setPeriod] = useState<Period>('last_30d');
|
|
const [companyId, setCompanyId] = useState<string>(ALL_COMPANIES);
|
|
const [issueType, setIssueType] = useState<string>(ALL_ISSUE_TYPES);
|
|
const [searchInput, setSearchInput] = useState('');
|
|
const [search, setSearch] = useState('');
|
|
const [page, setPage] = useState(0);
|
|
|
|
const [tickets, setTickets] = useState<TicketRow[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const [filterOptions, setFilterOptions] = useState<FilterOptions | null>(null);
|
|
|
|
// Debounce search input → applied search
|
|
useEffect(() => {
|
|
const t = setTimeout(() => {
|
|
setSearch(searchInput.trim());
|
|
setPage(0);
|
|
}, 300);
|
|
return () => clearTimeout(t);
|
|
}, [searchInput]);
|
|
|
|
// Reset page when filters change
|
|
useEffect(() => {
|
|
setPage(0);
|
|
}, [period, companyId, issueType]);
|
|
|
|
// Load filter options once
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
fetch('/api/analyzer/tickets/filter-options')
|
|
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
|
|
.then((data: FilterOptions) => {
|
|
if (!cancelled) setFilterOptions(data);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled)
|
|
setFilterOptions({ companies: [], issueTypes: [] });
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
const fetchTickets = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const params = new URLSearchParams({
|
|
period,
|
|
limit: String(PAGE_SIZE),
|
|
offset: String(page * PAGE_SIZE),
|
|
});
|
|
if (companyId !== ALL_COMPANIES) params.set('companyId', companyId);
|
|
if (issueType !== ALL_ISSUE_TYPES) params.set('issueType', issueType);
|
|
if (search) params.set('search', search);
|
|
const res = await fetch(`/api/analyzer/tickets/list?${params.toString()}`);
|
|
if (!res.ok) {
|
|
const data = (await res.json().catch(() => ({}))) as {
|
|
error?: string;
|
|
message?: string;
|
|
};
|
|
throw new Error(data.message ?? data.error ?? `Failed: ${res.status}`);
|
|
}
|
|
const data = (await res.json()) as {
|
|
tickets: TicketRow[];
|
|
total: number;
|
|
};
|
|
setTickets(data.tickets);
|
|
setTotal(data.total);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
setError(msg);
|
|
setTickets([]);
|
|
setTotal(0);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [period, companyId, issueType, search, page]);
|
|
|
|
useEffect(() => {
|
|
void fetchTickets();
|
|
}, [fetchTickets]);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
|
|
const activeFilterCount = useMemo(() => {
|
|
let n = 0;
|
|
if (period !== 'last_30d') n++;
|
|
if (companyId !== ALL_COMPANIES) n++;
|
|
if (issueType !== ALL_ISSUE_TYPES) n++;
|
|
if (search) n++;
|
|
return n;
|
|
}, [period, companyId, issueType, search]);
|
|
|
|
function clearFilters() {
|
|
setPeriod('last_30d');
|
|
setCompanyId(ALL_COMPANIES);
|
|
setIssueType(ALL_ISSUE_TYPES);
|
|
setSearchInput('');
|
|
setSearch('');
|
|
}
|
|
|
|
const companyName = useMemo(() => {
|
|
if (companyId === ALL_COMPANIES) return null;
|
|
return (
|
|
filterOptions?.companies.find((c) => c.id === companyId)?.name ?? null
|
|
);
|
|
}, [companyId, filterOptions]);
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 py-6 space-y-6 max-w-screen-2xl">
|
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">
|
|
Browse Tickets to Analyze
|
|
</h1>
|
|
<p className="text-muted-foreground text-sm mt-1">
|
|
Filter by activity window, client, or issue type — click Analyze to
|
|
run the AI pipeline against any ticket.
|
|
</p>
|
|
</div>
|
|
<Button variant="outline" asChild>
|
|
<Link href="/analyzer/queue">
|
|
<Filter className="w-4 h-4 mr-2" />
|
|
Needs review queue
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Filter bar */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<CardTitle className="text-base flex items-center gap-2">
|
|
<Filter className="w-4 h-4" />
|
|
Filters
|
|
{activeFilterCount > 0 && (
|
|
<Badge variant="secondary" className="ml-1">
|
|
{activeFilterCount}
|
|
</Badge>
|
|
)}
|
|
</CardTitle>
|
|
{activeFilterCount > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={clearFilters}
|
|
className="h-8"
|
|
>
|
|
<X className="w-3 h-3 mr-1" />
|
|
Clear all
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* Period chips */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
Period (last activity)
|
|
</Label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{PERIOD_OPTIONS.map((p) => (
|
|
<button
|
|
key={p.value}
|
|
type="button"
|
|
onClick={() => setPeriod(p.value)}
|
|
className={`px-3 py-1.5 rounded-full border text-sm transition ${
|
|
period === p.value
|
|
? 'bg-primary text-primary-foreground border-primary'
|
|
: 'bg-background hover:bg-accent border-border'
|
|
}`}
|
|
>
|
|
{p.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Other filters in a grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor="company-filter"
|
|
className="text-xs uppercase tracking-wide text-muted-foreground"
|
|
>
|
|
Client
|
|
</Label>
|
|
<Select value={companyId} onValueChange={setCompanyId}>
|
|
<SelectTrigger id="company-filter">
|
|
<SelectValue placeholder="All clients" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={ALL_COMPANIES}>All clients</SelectItem>
|
|
{(filterOptions?.companies ?? []).map((c) => (
|
|
<SelectItem key={c.id} value={c.id}>
|
|
{c.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor="issue-filter"
|
|
className="text-xs uppercase tracking-wide text-muted-foreground"
|
|
>
|
|
Issue type
|
|
</Label>
|
|
<Select value={issueType} onValueChange={setIssueType}>
|
|
<SelectTrigger id="issue-filter">
|
|
<SelectValue placeholder="All issue types" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={ALL_ISSUE_TYPES}>All issue types</SelectItem>
|
|
{(filterOptions?.issueTypes ?? []).map((it) => (
|
|
<SelectItem key={it.value} value={String(it.value)}>
|
|
{it.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor="search-filter"
|
|
className="text-xs uppercase tracking-wide text-muted-foreground"
|
|
>
|
|
Search
|
|
</Label>
|
|
<div className="relative">
|
|
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
id="search-filter"
|
|
className="pl-9"
|
|
placeholder="Ticket number or title…"
|
|
value={searchInput}
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Results */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
<CardTitle className="text-base">
|
|
{loading
|
|
? 'Loading…'
|
|
: total === 0
|
|
? 'No tickets match'
|
|
: total === 1
|
|
? '1 ticket'
|
|
: `${total.toLocaleString()} tickets`}
|
|
{companyName && total > 0 && (
|
|
<span className="font-normal text-muted-foreground ml-2">
|
|
· {companyName}
|
|
</span>
|
|
)}
|
|
</CardTitle>
|
|
{total > PAGE_SIZE && (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<span className="text-muted-foreground">
|
|
Page {page + 1} of {totalPages}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={page === 0 || loading}
|
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
>
|
|
Prev
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={page + 1 >= totalPages || loading}
|
|
onClick={() => setPage((p) => p + 1)}
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
{error ? (
|
|
<div className="p-6 text-sm text-destructive">{error}</div>
|
|
) : tickets.length === 0 && !loading ? (
|
|
<div className="p-12 text-center text-muted-foreground">
|
|
<Sparkles className="w-8 h-8 mx-auto mb-3 opacity-40" />
|
|
<p>No tickets match the current filters.</p>
|
|
<p className="text-xs mt-1">
|
|
Try widening the period or clearing some filters.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-[140px]">Ticket</TableHead>
|
|
<TableHead>Title</TableHead>
|
|
<TableHead className="w-[180px]">Client</TableHead>
|
|
<TableHead className="w-[140px]">Issue type</TableHead>
|
|
<TableHead className="w-[110px]">Status</TableHead>
|
|
<TableHead className="w-[110px]">Last activity</TableHead>
|
|
<TableHead className="w-[200px] text-right">
|
|
Actions
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{tickets.map((t) => (
|
|
<TableRow key={t.ticketNumber}>
|
|
<TableCell className="font-mono text-xs">
|
|
<Link
|
|
href={`/analyzer/ticket/${encodeURIComponent(t.ticketNumber)}`}
|
|
className="hover:underline"
|
|
>
|
|
{t.ticketNumber}
|
|
</Link>
|
|
</TableCell>
|
|
<TableCell className="max-w-md">
|
|
<div className="flex items-start gap-2">
|
|
<span className="line-clamp-2">
|
|
{t.title ?? <span className="text-muted-foreground italic">No title</span>}
|
|
</span>
|
|
{t.latestAnalysisId && (
|
|
<Badge
|
|
variant="outline"
|
|
className="shrink-0 text-emerald-700 border-emerald-300 dark:text-emerald-400 dark:border-emerald-800"
|
|
title={`Analyzed (v${t.latestAnalysisVersion})`}
|
|
>
|
|
<CheckCircle2 className="w-3 h-3 mr-1" />
|
|
v{t.latestAnalysisVersion}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-sm">
|
|
{t.companyName ?? <span className="text-muted-foreground">—</span>}
|
|
</TableCell>
|
|
<TableCell className="text-sm">
|
|
{t.issueTypeLabel ?? <span className="text-muted-foreground">—</span>}
|
|
</TableCell>
|
|
<TableCell className="text-sm">
|
|
{t.statusLabel ? (
|
|
<Badge variant="secondary">{t.statusLabel}</Badge>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell
|
|
className="text-sm text-muted-foreground"
|
|
title={t.lastActivityDate ?? ''}
|
|
>
|
|
{formatRelative(t.lastActivityDate)}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex justify-end gap-2">
|
|
{t.latestAnalysisId && (
|
|
<Button variant="outline" size="sm" asChild>
|
|
<Link href={`/analyzer/analysis/${t.latestAnalysisId}`}>
|
|
View
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
<AnalyzeButton
|
|
ticketNumber={t.ticketNumber}
|
|
variant={t.latestAnalysisId ? 'outline' : 'default'}
|
|
label={t.latestAnalysisId ? 'Re-analyze' : 'Analyze'}
|
|
force={!!t.latestAnalysisId}
|
|
/>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|