wulf-pulse/app/analyzer/tickets/page.tsx
lorentz 96edfb4444 feat(07.1-05): user-tz on analyzer pages
- itglue/applications, applications/[id], configurations,
  configurations/[id], sites/[companyId], queue, ticket/[ticketNumber],
  tickets, reports, reports/[id]: useUserTimezone() in default export;
  thread tz into every inline toLocale*String call.
- analyzer/tickets/page.tsx converts module-scope formatRelative(iso)
  helper to formatRelative(iso, tz); updates 1 callsite.

Migrates 16 of 81 audit leak callsites.
2026-05-07 08:34:58 -04:00

979 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';
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
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, tz: string): 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(undefined, { timeZone: tz });
}
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 tz = useUserTimezone();
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, tz)}
</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" />
);
}