Eight sub-phases per docs/ticket-analyzer-phase2-spec.md:
2.1 Schema (migration 070): analyzer_stage_executions table; source_snapshot,
aggregate_fingerprint, fingerprint_generated_at columns on analyzer_analyses.
model_traces marked LEGACY (kept for back-compat).
2.2 Every pipeline stage records a row to analyzer_stage_executions, success
or failure. Worker persists a status='failed' analyzer_analyses row when
the pipeline throws so partial stage records have a parent. Pipeline
exposes raw triage/sonnet/opus responses for downstream stages.
2.3 Stage 3 prompt updated with markdown formatting rules + banned filler
phrases. Added react-markdown + remark-gfm + @tailwindcss/typography.
New <AnalysisMarkdown> component replaces <ProseText>; coerces stray
headers to bold paragraphs.
2.4 Stage 6 fingerprint (Haiku) runs after persistence, failure-tolerant.
scripts/backfill-fingerprints.ts reconstructs Stage 6 input from the
legacy model_traces blob.
2.5 Browse UI rebuild at /analyzer/tickets: multi-select for client/issue/
queue/status/priority/assignee, sticky filter bar, active-filter chips,
bulk selection persisted via localStorage, "Analyze N selected" +
"Generate aggregate report" actions. New <MultiSelect> primitive.
Staleness uses last_activity_date > completed_at heuristic per spec C.1.
2.6 Aggregate reports (migration 071): runner is fire-and-forget, persists
SQL distributions immediately so UI shows partial state during the
Sonnet reduce call. Three endpoints, three pages (/analyzer/reports[/new
/:id]). IT Glue context fetcher capped at 200 doc titles.
2.7 Cost guards (migration 072): per-request $5 confirmation, soft-warn at
$20/day, hard-block at $50/day with ANALYZER_DAILY_COST_OVERRIDE_USERS
override. Every gating decision audited.
2.8 Runbook + build notes updated.
128 vitest tests passing, tsc clean. Migrations 070/071/072 idempotent
(IF NOT EXISTS). model_traces double-write retained — drop in a future
migration once aggregate reports have soaked.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
977 lines
32 KiB
TypeScript
977 lines
32 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import { useRouter } from 'next/navigation';
|
|
import { Card, CardContent } 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 { Checkbox } from '@/components/ui/checkbox';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import {
|
|
MultiSelect,
|
|
type MultiSelectOption,
|
|
} from '@/components/ui/multi-select';
|
|
import {
|
|
Search,
|
|
Sparkles,
|
|
CheckCircle2,
|
|
Filter as FilterIcon,
|
|
X,
|
|
AlertTriangle,
|
|
CircleDot,
|
|
Clock,
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
type Period =
|
|
| 'today'
|
|
| 'yesterday'
|
|
| 'this_week'
|
|
| 'last_week'
|
|
| 'last_30d'
|
|
| 'last_60d'
|
|
| 'custom'
|
|
| 'all';
|
|
|
|
const PERIOD_OPTIONS: { value: Period; label: string }[] = [
|
|
{ value: 'today', label: 'Today' },
|
|
{ value: 'yesterday', label: 'Yesterday' },
|
|
{ value: 'this_week', label: 'This week' },
|
|
{ value: 'last_week', label: 'Last week' },
|
|
{ value: 'last_30d', label: '30d' },
|
|
{ value: 'last_60d', label: '60d' },
|
|
{ value: 'custom', label: 'Custom' },
|
|
{ value: 'all', label: 'All' },
|
|
];
|
|
|
|
type AnalyzedFilter = 'any' | 'yes' | 'no' | 'stale';
|
|
|
|
interface TicketRow {
|
|
ticketNumber: string;
|
|
autotaskTicketId: number;
|
|
title: string | null;
|
|
clientName: string | null;
|
|
clientId: number | null;
|
|
status: string | null;
|
|
priority: string | null;
|
|
queue: string | null;
|
|
issueType: string | null;
|
|
subIssueType: string | null;
|
|
assignedResourceName: string | null;
|
|
createdAtAutotask: string | null;
|
|
lastActivityAtAutotask: string | null;
|
|
ageInDays: number | null;
|
|
analyzedState: 'none' | 'current' | 'stale';
|
|
latestAnalysisId: string | null;
|
|
latestAnalysisAt: string | null;
|
|
needsHumanReview: boolean;
|
|
confidenceScore: number | null;
|
|
primaryCategory: string | null;
|
|
}
|
|
|
|
interface FilterOptions {
|
|
companies: { id: string; name: string }[];
|
|
issueTypes: { value: number; label: string }[];
|
|
queues: { value: number; label: string }[];
|
|
statuses: { value: number; label: string }[];
|
|
priorities: { value: number; label: string }[];
|
|
resources: { id: string; name: string }[];
|
|
}
|
|
|
|
const PAGE_SIZE = 50;
|
|
const SELECTION_LS_KEY = 'analyzer:ticket-selection:v1';
|
|
|
|
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();
|
|
}
|
|
|
|
function formatAge(days: number | null): string {
|
|
if (days === null) return '—';
|
|
if (days < 1) return '<1d';
|
|
if (days < 30) return `${days}d`;
|
|
const months = Math.round(days / 30);
|
|
return `${months}mo`;
|
|
}
|
|
|
|
function readSelection(): string[] {
|
|
if (typeof window === 'undefined') return [];
|
|
try {
|
|
const raw = localStorage.getItem(SELECTION_LS_KEY);
|
|
if (!raw) return [];
|
|
const parsed = JSON.parse(raw);
|
|
return Array.isArray(parsed) ? parsed.filter((s) => typeof s === 'string') : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function writeSelection(ids: string[]) {
|
|
if (typeof window === 'undefined') return;
|
|
try {
|
|
localStorage.setItem(SELECTION_LS_KEY, JSON.stringify(ids));
|
|
} catch {
|
|
/* quota / disabled — ignore */
|
|
}
|
|
}
|
|
|
|
export default function AnalyzerBrowseTicketsPage() {
|
|
const router = useRouter();
|
|
|
|
const [period, setPeriod] = useState<Period>('last_30d');
|
|
const [startDate, setStartDate] = useState('');
|
|
const [endDate, setEndDate] = useState('');
|
|
const [clientIds, setClientIds] = useState<string[]>([]);
|
|
const [issueTypes, setIssueTypes] = useState<string[]>([]);
|
|
const [queues, setQueues] = useState<string[]>([]);
|
|
const [statuses, setStatuses] = useState<string[]>([]);
|
|
const [priorities, setPriorities] = useState<string[]>([]);
|
|
const [assignedTo, setAssignedTo] = useState<string[]>([]);
|
|
const [analyzed, setAnalyzed] = useState<AnalyzedFilter>('any');
|
|
const [needsReview, setNeedsReview] = useState(false);
|
|
const [searchInput, setSearchInput] = useState('');
|
|
const [search, setSearch] = useState('');
|
|
const [page, setPage] = useState(0);
|
|
const [sort, setSort] = useState<
|
|
| 'last_activity_desc'
|
|
| 'last_activity_asc'
|
|
| 'created_desc'
|
|
| 'created_asc'
|
|
| 'priority'
|
|
>('last_activity_desc');
|
|
|
|
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);
|
|
|
|
// Bulk selection (session-scoped via localStorage)
|
|
const [selected, setSelected] = useState<string[]>(() => readSelection());
|
|
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
|
|
|
// Bulk-action busy state (Analyze N tickets sequential)
|
|
const [bulkRunning, setBulkRunning] = useState(false);
|
|
|
|
// Debounce search input
|
|
useEffect(() => {
|
|
const t = setTimeout(() => {
|
|
setSearch(searchInput.trim());
|
|
setPage(0);
|
|
}, 300);
|
|
return () => clearTimeout(t);
|
|
}, [searchInput]);
|
|
|
|
// Reset to page 0 on any filter change
|
|
useEffect(() => {
|
|
setPage(0);
|
|
}, [
|
|
period,
|
|
startDate,
|
|
endDate,
|
|
clientIds,
|
|
issueTypes,
|
|
queues,
|
|
statuses,
|
|
priorities,
|
|
assignedTo,
|
|
analyzed,
|
|
needsReview,
|
|
sort,
|
|
]);
|
|
|
|
// 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: [],
|
|
queues: [],
|
|
statuses: [],
|
|
priorities: [],
|
|
resources: [],
|
|
});
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
// Persist selection on change
|
|
useEffect(() => {
|
|
writeSelection(selected);
|
|
}, [selected]);
|
|
|
|
const fetchTickets = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const params = new URLSearchParams({
|
|
period,
|
|
sort,
|
|
limit: String(PAGE_SIZE),
|
|
offset: String(page * PAGE_SIZE),
|
|
});
|
|
if (period === 'custom' && startDate && endDate) {
|
|
params.set('startDate', startDate);
|
|
params.set('endDate', endDate);
|
|
}
|
|
if (clientIds.length) params.set('clientId', clientIds.join(','));
|
|
if (issueTypes.length) params.set('issueType', issueTypes.join(','));
|
|
if (queues.length) params.set('queue', queues.join(','));
|
|
if (statuses.length) params.set('status', statuses.join(','));
|
|
if (priorities.length) params.set('priority', priorities.join(','));
|
|
if (assignedTo.length) params.set('assignedTo', assignedTo.join(','));
|
|
if (analyzed !== 'any') params.set('analyzed', analyzed);
|
|
if (needsReview) params.set('needsReview', 'true');
|
|
if (search) params.set('search', search);
|
|
|
|
const res = await fetch(`/api/analyzer/tickets?${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) {
|
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
setTickets([]);
|
|
setTotal(0);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [
|
|
period,
|
|
startDate,
|
|
endDate,
|
|
clientIds,
|
|
issueTypes,
|
|
queues,
|
|
statuses,
|
|
priorities,
|
|
assignedTo,
|
|
analyzed,
|
|
needsReview,
|
|
search,
|
|
sort,
|
|
page,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
void fetchTickets();
|
|
}, [fetchTickets]);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
|
|
const activeFilters = useMemo(() => {
|
|
const chips: { key: string; label: string; clear: () => void }[] = [];
|
|
if (period !== 'last_30d')
|
|
chips.push({
|
|
key: 'period',
|
|
label:
|
|
period === 'custom' && startDate && endDate
|
|
? `${startDate} → ${endDate}`
|
|
: (PERIOD_OPTIONS.find((p) => p.value === period)?.label ?? period),
|
|
clear: () => {
|
|
setPeriod('last_30d');
|
|
setStartDate('');
|
|
setEndDate('');
|
|
},
|
|
});
|
|
const lookup = (
|
|
arr: { value: number | string; label: string }[] | undefined,
|
|
sel: string[]
|
|
) =>
|
|
sel
|
|
.map(
|
|
(s) =>
|
|
arr?.find((o) => String(o.value ?? (o as { id?: string }).id ?? '') === s)
|
|
?.label ?? s
|
|
)
|
|
.join(', ');
|
|
if (clientIds.length)
|
|
chips.push({
|
|
key: 'client',
|
|
label: `Client: ${clientIds.length}`,
|
|
clear: () => setClientIds([]),
|
|
});
|
|
if (issueTypes.length)
|
|
chips.push({
|
|
key: 'issue',
|
|
label: `Issue type: ${lookup(filterOptions?.issueTypes, issueTypes)}`,
|
|
clear: () => setIssueTypes([]),
|
|
});
|
|
if (queues.length)
|
|
chips.push({
|
|
key: 'queue',
|
|
label: `Queue: ${lookup(filterOptions?.queues, queues)}`,
|
|
clear: () => setQueues([]),
|
|
});
|
|
if (statuses.length)
|
|
chips.push({
|
|
key: 'status',
|
|
label: `Status: ${lookup(filterOptions?.statuses, statuses)}`,
|
|
clear: () => setStatuses([]),
|
|
});
|
|
if (priorities.length)
|
|
chips.push({
|
|
key: 'priority',
|
|
label: `Priority: ${lookup(filterOptions?.priorities, priorities)}`,
|
|
clear: () => setPriorities([]),
|
|
});
|
|
if (assignedTo.length)
|
|
chips.push({
|
|
key: 'assigned',
|
|
label: `Assigned: ${assignedTo.length}`,
|
|
clear: () => setAssignedTo([]),
|
|
});
|
|
if (analyzed !== 'any')
|
|
chips.push({
|
|
key: 'analyzed',
|
|
label: `Analyzed: ${analyzed}`,
|
|
clear: () => setAnalyzed('any'),
|
|
});
|
|
if (needsReview)
|
|
chips.push({
|
|
key: 'needsReview',
|
|
label: 'Needs review',
|
|
clear: () => setNeedsReview(false),
|
|
});
|
|
if (search)
|
|
chips.push({
|
|
key: 'search',
|
|
label: `"${search}"`,
|
|
clear: () => {
|
|
setSearchInput('');
|
|
setSearch('');
|
|
},
|
|
});
|
|
return chips;
|
|
}, [
|
|
period,
|
|
startDate,
|
|
endDate,
|
|
clientIds,
|
|
issueTypes,
|
|
queues,
|
|
statuses,
|
|
priorities,
|
|
assignedTo,
|
|
analyzed,
|
|
needsReview,
|
|
search,
|
|
filterOptions,
|
|
]);
|
|
|
|
function clearAll() {
|
|
setPeriod('last_30d');
|
|
setStartDate('');
|
|
setEndDate('');
|
|
setClientIds([]);
|
|
setIssueTypes([]);
|
|
setQueues([]);
|
|
setStatuses([]);
|
|
setPriorities([]);
|
|
setAssignedTo([]);
|
|
setAnalyzed('any');
|
|
setNeedsReview(false);
|
|
setSearchInput('');
|
|
setSearch('');
|
|
}
|
|
|
|
const visibleSelectableCount = tickets.length;
|
|
const visibleSelectedCount = tickets.filter((t) =>
|
|
selectedSet.has(t.ticketNumber)
|
|
).length;
|
|
const allOnPageSelected =
|
|
visibleSelectableCount > 0 &&
|
|
visibleSelectedCount === visibleSelectableCount;
|
|
const someOnPageSelected =
|
|
visibleSelectedCount > 0 && !allOnPageSelected;
|
|
|
|
function toggleSelectAllOnPage() {
|
|
if (allOnPageSelected) {
|
|
setSelected((prev) =>
|
|
prev.filter((id) => !tickets.some((t) => t.ticketNumber === id))
|
|
);
|
|
} else {
|
|
const merged = new Set(selected);
|
|
tickets.forEach((t) => merged.add(t.ticketNumber));
|
|
setSelected(Array.from(merged));
|
|
}
|
|
}
|
|
|
|
function toggleRow(ticketNumber: string) {
|
|
setSelected((prev) =>
|
|
prev.includes(ticketNumber)
|
|
? prev.filter((id) => id !== ticketNumber)
|
|
: [...prev, ticketNumber]
|
|
);
|
|
}
|
|
|
|
function clearSelection() {
|
|
setSelected([]);
|
|
}
|
|
|
|
// Lookup of currently-selected tickets in the loaded set, for action gating.
|
|
const selectedRows = useMemo(
|
|
() => tickets.filter((t) => selectedSet.has(t.ticketNumber)),
|
|
[tickets, selectedSet]
|
|
);
|
|
const selectedAllAnalyzedAndCurrent = useMemo(() => {
|
|
if (selected.length === 0) return false;
|
|
if (selectedRows.length !== selected.length) return false; // some selections off-page
|
|
return selectedRows.every((r) => r.analyzedState === 'current');
|
|
}, [selectedRows, selected]);
|
|
const selectedAnyNeedsAnalyze = useMemo(() => {
|
|
if (selectedRows.length === 0) return false;
|
|
return selectedRows.some(
|
|
(r) => r.analyzedState === 'none' || r.analyzedState === 'stale'
|
|
);
|
|
}, [selectedRows]);
|
|
|
|
async function bulkAnalyze() {
|
|
if (selectedRows.length === 0) return;
|
|
const targets = selectedRows.filter(
|
|
(r) => r.analyzedState !== 'current'
|
|
);
|
|
if (targets.length === 0) {
|
|
toast.info('All selected tickets already have a current analysis.');
|
|
return;
|
|
}
|
|
if (
|
|
!confirm(
|
|
`Analyze ${targets.length} ticket${targets.length === 1 ? '' : 's'}? This will queue ${targets.length} job${targets.length === 1 ? '' : 's'}.`
|
|
)
|
|
)
|
|
return;
|
|
setBulkRunning(true);
|
|
let queued = 0;
|
|
let failed = 0;
|
|
for (const t of targets) {
|
|
try {
|
|
const res = await fetch(
|
|
`/api/analyzer/tickets/${encodeURIComponent(t.ticketNumber)}/analyze`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ force: t.analyzedState === 'stale' }),
|
|
}
|
|
);
|
|
if (res.ok) queued++;
|
|
else failed++;
|
|
} catch {
|
|
failed++;
|
|
}
|
|
}
|
|
setBulkRunning(false);
|
|
toast.success(
|
|
`Queued ${queued} job${queued === 1 ? '' : 's'}${failed ? ` (${failed} failed)` : ''}. Worker will pick them up shortly.`
|
|
);
|
|
void fetchTickets();
|
|
}
|
|
|
|
function bulkAggregateReport() {
|
|
if (!selectedAllAnalyzedAndCurrent) {
|
|
toast.error(
|
|
'All selected tickets must be analyzed and current before generating an aggregate report.'
|
|
);
|
|
return;
|
|
}
|
|
const params = new URLSearchParams({
|
|
ids: selected.join(','),
|
|
});
|
|
router.push(`/analyzer/reports/new?${params.toString()}`);
|
|
}
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 py-6 space-y-4 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, select, and analyze in bulk. Selections persist across
|
|
pagination via localStorage.
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" asChild>
|
|
<Link href="/analyzer/reports">Reports</Link>
|
|
</Button>
|
|
<Button variant="outline" asChild>
|
|
<Link href="/analyzer/queue">Needs review</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sticky filter bar */}
|
|
<div className="sticky top-0 z-30 -mx-4 px-4 pb-2 pt-2 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-b">
|
|
<div className="space-y-3">
|
|
{/* Period pills */}
|
|
<div className="flex flex-wrap gap-1.5 items-center">
|
|
{PERIOD_OPTIONS.map((p) => (
|
|
<button
|
|
key={p.value}
|
|
type="button"
|
|
onClick={() => setPeriod(p.value)}
|
|
className={`px-3 py-1 rounded-full border text-xs transition ${
|
|
period === p.value
|
|
? 'bg-primary text-primary-foreground border-primary'
|
|
: 'bg-background hover:bg-accent border-border'
|
|
}`}
|
|
>
|
|
{p.label}
|
|
</button>
|
|
))}
|
|
{period === 'custom' && (
|
|
<div className="flex items-center gap-1.5 ml-2">
|
|
<Input
|
|
type="date"
|
|
value={startDate}
|
|
onChange={(e) => setStartDate(e.target.value)}
|
|
className="h-8 w-[140px]"
|
|
/>
|
|
<span className="text-muted-foreground text-xs">→</span>
|
|
<Input
|
|
type="date"
|
|
value={endDate}
|
|
onChange={(e) => setEndDate(e.target.value)}
|
|
className="h-8 w-[140px]"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Multi-selects + analyzed + search */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-2">
|
|
<MultiSelect
|
|
options={
|
|
(filterOptions?.companies ?? []).map((c) => ({
|
|
value: c.id,
|
|
label: c.name,
|
|
})) as MultiSelectOption[]
|
|
}
|
|
value={clientIds}
|
|
onChange={setClientIds}
|
|
placeholder="Client"
|
|
searchPlaceholder="Search clients…"
|
|
/>
|
|
<MultiSelect
|
|
options={
|
|
(filterOptions?.issueTypes ?? []).map((it) => ({
|
|
value: String(it.value),
|
|
label: it.label,
|
|
})) as MultiSelectOption[]
|
|
}
|
|
value={issueTypes}
|
|
onChange={setIssueTypes}
|
|
placeholder="Issue type"
|
|
/>
|
|
<MultiSelect
|
|
options={
|
|
(filterOptions?.queues ?? []).map((q) => ({
|
|
value: String(q.value),
|
|
label: q.label,
|
|
})) as MultiSelectOption[]
|
|
}
|
|
value={queues}
|
|
onChange={setQueues}
|
|
placeholder="Queue"
|
|
/>
|
|
<MultiSelect
|
|
options={
|
|
(filterOptions?.statuses ?? []).map((s) => ({
|
|
value: String(s.value),
|
|
label: s.label,
|
|
})) as MultiSelectOption[]
|
|
}
|
|
value={statuses}
|
|
onChange={setStatuses}
|
|
placeholder="Status"
|
|
/>
|
|
<MultiSelect
|
|
options={
|
|
(filterOptions?.priorities ?? []).map((p) => ({
|
|
value: String(p.value),
|
|
label: p.label,
|
|
})) as MultiSelectOption[]
|
|
}
|
|
value={priorities}
|
|
onChange={setPriorities}
|
|
placeholder="Priority"
|
|
/>
|
|
<MultiSelect
|
|
options={
|
|
(filterOptions?.resources ?? []).map((r) => ({
|
|
value: r.id,
|
|
label: r.name,
|
|
})) as MultiSelectOption[]
|
|
}
|
|
value={assignedTo}
|
|
onChange={setAssignedTo}
|
|
placeholder="Assigned to"
|
|
searchPlaceholder="Search resources…"
|
|
/>
|
|
<div className="relative">
|
|
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
className="pl-8 h-9"
|
|
placeholder="Search…"
|
|
value={searchInput}
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Analyzed segmented + needs review + sort + clear */}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="flex items-center gap-1 rounded-md border bg-background p-0.5">
|
|
{(['any', 'yes', 'no', 'stale'] as const).map((opt) => (
|
|
<button
|
|
key={opt}
|
|
type="button"
|
|
onClick={() => setAnalyzed(opt)}
|
|
className={`px-2.5 py-1 rounded text-xs capitalize transition ${
|
|
analyzed === opt
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'hover:bg-accent'
|
|
}`}
|
|
>
|
|
{opt === 'yes'
|
|
? 'Analyzed'
|
|
: opt === 'no'
|
|
? 'Not analyzed'
|
|
: opt === 'stale'
|
|
? 'Has new activity'
|
|
: 'Any'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<Label className="flex items-center gap-2 text-sm">
|
|
<Checkbox
|
|
checked={needsReview}
|
|
onCheckedChange={(c) => setNeedsReview(c === true)}
|
|
/>
|
|
Needs review
|
|
</Label>
|
|
<div className="flex items-center gap-2 ml-auto">
|
|
<Label className="text-xs text-muted-foreground">Sort</Label>
|
|
<Select value={sort} onValueChange={(v) => setSort(v as typeof sort)}>
|
|
<SelectTrigger className="h-9 w-[180px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="last_activity_desc">Last activity ↓</SelectItem>
|
|
<SelectItem value="last_activity_asc">Last activity ↑</SelectItem>
|
|
<SelectItem value="created_desc">Newest first</SelectItem>
|
|
<SelectItem value="created_asc">Oldest first</SelectItem>
|
|
<SelectItem value="priority">Priority</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
{activeFilters.length > 0 && (
|
|
<Button variant="ghost" size="sm" onClick={clearAll}>
|
|
<X className="w-3 h-3 mr-1" />
|
|
Clear
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Active filter chips */}
|
|
{activeFilters.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{activeFilters.map((f) => (
|
|
<Badge
|
|
key={f.key}
|
|
variant="secondary"
|
|
className="gap-1 cursor-pointer hover:bg-destructive hover:text-destructive-foreground"
|
|
onClick={f.clear}
|
|
>
|
|
{f.label}
|
|
<X className="w-3 h-3" />
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bulk actions + count */}
|
|
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
|
|
<div className="text-sm text-muted-foreground flex items-center gap-3">
|
|
<FilterIcon className="w-4 h-4" />
|
|
{loading
|
|
? 'Loading…'
|
|
: `${total.toLocaleString()} ticket${total === 1 ? '' : 's'}`}
|
|
{selected.length > 0 && (
|
|
<Badge variant="secondary">{selected.length} selected</Badge>
|
|
)}
|
|
{selected.length > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7"
|
|
onClick={clearSelection}
|
|
>
|
|
Clear selection
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
disabled={!selectedAnyNeedsAnalyze || bulkRunning}
|
|
onClick={bulkAnalyze}
|
|
title={
|
|
!selectedAnyNeedsAnalyze
|
|
? 'Select tickets that are not yet analyzed (or are stale)'
|
|
: ''
|
|
}
|
|
>
|
|
<Sparkles className="w-4 h-4 mr-2" />
|
|
{bulkRunning
|
|
? 'Queueing…'
|
|
: `Analyze ${selectedRows.filter((r) => r.analyzedState !== 'current').length || ''} selected`}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
disabled={!selectedAllAnalyzedAndCurrent}
|
|
onClick={bulkAggregateReport}
|
|
title={
|
|
!selectedAllAnalyzedAndCurrent
|
|
? 'All selected tickets must be analyzed and current'
|
|
: ''
|
|
}
|
|
>
|
|
Generate aggregate report
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Results table */}
|
|
<Card>
|
|
<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-[42px]">
|
|
<Checkbox
|
|
checked={
|
|
allOnPageSelected
|
|
? true
|
|
: someOnPageSelected
|
|
? 'indeterminate'
|
|
: false
|
|
}
|
|
onCheckedChange={toggleSelectAllOnPage}
|
|
aria-label="Select all on page"
|
|
/>
|
|
</TableHead>
|
|
<TableHead className="w-[42px]">St.</TableHead>
|
|
<TableHead className="w-[140px]">Ticket</TableHead>
|
|
<TableHead>Title</TableHead>
|
|
<TableHead className="w-[160px]">Client</TableHead>
|
|
<TableHead className="w-[110px]">Status</TableHead>
|
|
<TableHead className="w-[90px]">Priority</TableHead>
|
|
<TableHead className="w-[90px]">Age</TableHead>
|
|
<TableHead className="w-[110px]">Last activity</TableHead>
|
|
<TableHead className="w-[120px]">Assigned</TableHead>
|
|
<TableHead className="w-[150px] text-right">Action</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{tickets.map((t) => (
|
|
<TableRow
|
|
key={t.ticketNumber}
|
|
data-selected={selectedSet.has(t.ticketNumber)}
|
|
className="data-[selected=true]:bg-accent/40"
|
|
>
|
|
<TableCell>
|
|
<Checkbox
|
|
checked={selectedSet.has(t.ticketNumber)}
|
|
onCheckedChange={() => toggleRow(t.ticketNumber)}
|
|
aria-label={`Select ${t.ticketNumber}`}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<AnalyzedDot state={t.analyzedState} />
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs">
|
|
<Link
|
|
href={`/analyzer/ticket/${encodeURIComponent(t.ticketNumber)}`}
|
|
className="hover:underline"
|
|
>
|
|
{t.ticketNumber}
|
|
</Link>
|
|
</TableCell>
|
|
<TableCell className="max-w-[480px]">
|
|
<div className="flex items-start gap-2">
|
|
<span className="line-clamp-2 text-sm">
|
|
{t.title ?? (
|
|
<span className="text-muted-foreground italic">
|
|
No title
|
|
</span>
|
|
)}
|
|
</span>
|
|
{t.needsHumanReview && (
|
|
<Badge
|
|
variant="outline"
|
|
className="shrink-0 border-amber-400 text-amber-700 dark:text-amber-400"
|
|
title="Needs human review"
|
|
>
|
|
<AlertTriangle className="w-3 h-3 mr-1" />
|
|
Review
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-sm truncate max-w-[160px]">
|
|
{t.clientName ?? <span className="text-muted-foreground">—</span>}
|
|
</TableCell>
|
|
<TableCell className="text-xs">
|
|
{t.status ? (
|
|
<Badge variant="secondary" className="font-normal">
|
|
{t.status}
|
|
</Badge>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-xs">
|
|
{t.priority ?? <span className="text-muted-foreground">—</span>}
|
|
</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground">
|
|
{formatAge(t.ageInDays)}
|
|
</TableCell>
|
|
<TableCell
|
|
className="text-xs text-muted-foreground"
|
|
title={t.lastActivityAtAutotask ?? ''}
|
|
>
|
|
{formatRelative(t.lastActivityAtAutotask)}
|
|
</TableCell>
|
|
<TableCell className="text-xs truncate max-w-[120px]">
|
|
{t.assignedResourceName ?? (
|
|
<span className="text-muted-foreground">—</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{t.latestAnalysisId ? (
|
|
<Button variant="outline" size="sm" asChild>
|
|
<Link href={`/analyzer/analysis/${t.latestAnalysisId}`}>
|
|
View analysis
|
|
</Link>
|
|
</Button>
|
|
) : (
|
|
<Button variant="default" size="sm" asChild>
|
|
<Link
|
|
href={`/analyzer/ticket/${encodeURIComponent(t.ticketNumber)}`}
|
|
>
|
|
Analyze
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
|
|
{total > PAGE_SIZE && (
|
|
<div className="flex items-center justify-between p-3 text-sm border-t">
|
|
<span className="text-muted-foreground">
|
|
Page {page + 1} of {totalPages}
|
|
</span>
|
|
<div className="flex gap-2">
|
|
<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>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AnalyzedDot({ state }: { state: 'none' | 'current' | 'stale' }) {
|
|
if (state === 'current') {
|
|
return (
|
|
<CheckCircle2
|
|
className="w-4 h-4 text-emerald-500"
|
|
aria-label="Analyzed (current)"
|
|
/>
|
|
);
|
|
}
|
|
if (state === 'stale') {
|
|
return (
|
|
<Clock className="w-4 h-4 text-amber-500" aria-label="Analyzed (stale)" />
|
|
);
|
|
}
|
|
return (
|
|
<CircleDot className="w-4 h-4 text-muted-foreground/40" aria-label="Not analyzed" />
|
|
);
|
|
}
|