feat(analyzer): browse-tickets page + analysis-view typography
- /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>
This commit is contained in:
parent
966376e6b6
commit
b20c94ea1a
6 changed files with 879 additions and 19 deletions
487
app/analyzer/tickets/page.tsx
Normal file
487
app/analyzer/tickets/page.tsx
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
55
app/api/analyzer/tickets/filter-options/route.ts
Normal file
55
app/api/analyzer/tickets/filter-options/route.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* GET /api/analyzer/tickets/filter-options
|
||||
*
|
||||
* Returns the dropdown source data for the analyzer ticket browser.
|
||||
* Companies are limited to those that have at least one non-deleted ticket
|
||||
* (255 → typically ~150 with tickets) so the dropdown isn't padded with
|
||||
* dormant accounts.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
interface CompanyRow {
|
||||
id: string;
|
||||
company_name: string;
|
||||
}
|
||||
interface IssueTypeRow {
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const [companies, issueTypes] = await Promise.all([
|
||||
postgresClient.query<CompanyRow>(
|
||||
`SELECT c.id::text AS id, c.company_name
|
||||
FROM companies c
|
||||
WHERE c.is_active = true
|
||||
AND c.is_deleted = false
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM tickets t
|
||||
WHERE t.company_id = c.id AND t.is_deleted = false
|
||||
)
|
||||
ORDER BY c.company_name`
|
||||
),
|
||||
postgresClient.query<IssueTypeRow>(
|
||||
`SELECT value, label
|
||||
FROM issue_types
|
||||
WHERE is_active = true
|
||||
AND is_deleted = false
|
||||
ORDER BY sort_order NULLS LAST, label`
|
||||
),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
companies: companies.rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.company_name,
|
||||
})),
|
||||
issueTypes: issueTypes.rows,
|
||||
});
|
||||
}
|
||||
177
app/api/analyzer/tickets/list/route.ts
Normal file
177
app/api/analyzer/tickets/list/route.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* GET /api/analyzer/tickets/list
|
||||
*
|
||||
* Browse view backing the /analyzer/tickets page. Filters tickets by
|
||||
* `last_activity_date` (the most useful axis for "what's worth analyzing
|
||||
* right now") plus optional company / issue type / free-text search.
|
||||
*
|
||||
* Query params:
|
||||
* period one of today | yesterday | this_week | last_week | last_30d | last_60d | all
|
||||
* companyId numeric companies.id, optional
|
||||
* issueType numeric issue_types.value, optional
|
||||
* search substring match against ticket_number or title
|
||||
* limit default 50, capped at 200
|
||||
* offset default 0
|
||||
*
|
||||
* Returns:
|
||||
* { tickets: TicketRow[], total: number }
|
||||
*
|
||||
* Each row carries `latestAnalysisId` if the ticket already has a complete
|
||||
* analysis, so the UI can offer "View analysis" alongside "Analyze".
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
type Period =
|
||||
| 'today'
|
||||
| 'yesterday'
|
||||
| 'this_week'
|
||||
| 'last_week'
|
||||
| 'last_30d'
|
||||
| 'last_60d'
|
||||
| 'all';
|
||||
|
||||
const ALLOWED_PERIODS: ReadonlySet<Period> = new Set([
|
||||
'today',
|
||||
'yesterday',
|
||||
'this_week',
|
||||
'last_week',
|
||||
'last_30d',
|
||||
'last_60d',
|
||||
'all',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns the SQL fragment for the date predicate. Uses Postgres-side NOW()
|
||||
* so "today" reflects the database server's clock — this is an internal tool
|
||||
* and the DB and app process share the same clock.
|
||||
*
|
||||
* Returns the predicate string with no parameters — these date expressions
|
||||
* are constants from the API perspective, computed in Postgres.
|
||||
*/
|
||||
function periodPredicate(period: Period): string {
|
||||
switch (period) {
|
||||
case 'today':
|
||||
return `t.last_activity_date >= date_trunc('day', NOW())`;
|
||||
case 'yesterday':
|
||||
return `t.last_activity_date >= date_trunc('day', NOW()) - INTERVAL '1 day'
|
||||
AND t.last_activity_date < date_trunc('day', NOW())`;
|
||||
case 'this_week':
|
||||
return `t.last_activity_date >= date_trunc('week', NOW())`;
|
||||
case 'last_week':
|
||||
return `t.last_activity_date >= date_trunc('week', NOW()) - INTERVAL '1 week'
|
||||
AND t.last_activity_date < date_trunc('week', NOW())`;
|
||||
case 'last_30d':
|
||||
return `t.last_activity_date >= NOW() - INTERVAL '30 days'`;
|
||||
case 'last_60d':
|
||||
return `t.last_activity_date >= NOW() - INTERVAL '60 days'`;
|
||||
case 'all':
|
||||
return `TRUE`;
|
||||
}
|
||||
}
|
||||
|
||||
interface TicketRow {
|
||||
ticket_number: string;
|
||||
title: string | null;
|
||||
company_name: string | null;
|
||||
issue_type_label: string | null;
|
||||
status_label: string | null;
|
||||
priority_label: string | null;
|
||||
last_activity_date: Date | null;
|
||||
create_date: Date | null;
|
||||
latest_analysis_id: string | null;
|
||||
latest_analysis_version: number | null;
|
||||
total_count: string;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const url = new URL(request.url);
|
||||
const periodParam = (url.searchParams.get('period') ?? 'last_30d') as Period;
|
||||
const period: Period = ALLOWED_PERIODS.has(periodParam) ? periodParam : 'last_30d';
|
||||
const companyIdRaw = url.searchParams.get('companyId');
|
||||
const companyId = companyIdRaw ? Number(companyIdRaw) : null;
|
||||
const issueTypeRaw = url.searchParams.get('issueType');
|
||||
const issueType = issueTypeRaw ? Number(issueTypeRaw) : null;
|
||||
const search = (url.searchParams.get('search') ?? '').trim() || null;
|
||||
const limit = Math.min(Number(url.searchParams.get('limit') ?? 50) || 50, 200);
|
||||
const offset = Math.max(Number(url.searchParams.get('offset') ?? 0) || 0, 0);
|
||||
|
||||
const sql = `
|
||||
WITH filtered AS (
|
||||
SELECT t.id, t.ticket_number, t.title,
|
||||
t.company_id, t.issue_type, t.status, t.priority,
|
||||
t.last_activity_date, t.create_date
|
||||
FROM tickets t
|
||||
WHERE t.is_deleted = false
|
||||
AND ${periodPredicate(period)}
|
||||
AND ($1::bigint IS NULL OR t.company_id = $1::bigint)
|
||||
AND ($2::int IS NULL OR t.issue_type = $2::int)
|
||||
AND ($3::text IS NULL OR (
|
||||
t.ticket_number ILIKE '%' || $3::text || '%'
|
||||
OR t.title ILIKE '%' || $3::text || '%'
|
||||
))
|
||||
)
|
||||
SELECT f.ticket_number,
|
||||
f.title,
|
||||
c.company_name,
|
||||
it.label AS issue_type_label,
|
||||
s.label AS status_label,
|
||||
pr.label AS priority_label,
|
||||
f.last_activity_date,
|
||||
f.create_date,
|
||||
latest.id::text AS latest_analysis_id,
|
||||
latest.analysis_version AS latest_analysis_version,
|
||||
COUNT(*) OVER () AS total_count
|
||||
FROM filtered f
|
||||
LEFT JOIN companies c ON c.id = f.company_id
|
||||
LEFT JOIN issue_types it ON it.value = f.issue_type
|
||||
LEFT JOIN statuses s ON s.value = f.status
|
||||
LEFT JOIN priorities pr ON pr.value = f.priority
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT aa.id, aa.analysis_version
|
||||
FROM analyzer_analyses aa
|
||||
WHERE aa.ticket_number = f.ticket_number
|
||||
AND aa.status = 'complete'
|
||||
ORDER BY aa.analysis_version DESC
|
||||
LIMIT 1
|
||||
) latest ON TRUE
|
||||
ORDER BY f.last_activity_date DESC NULLS LAST
|
||||
LIMIT $4 OFFSET $5
|
||||
`;
|
||||
|
||||
const res = await postgresClient.query<TicketRow>(sql, [
|
||||
companyId,
|
||||
issueType,
|
||||
search,
|
||||
limit,
|
||||
offset,
|
||||
]);
|
||||
|
||||
const total = res.rows.length > 0 ? Number(res.rows[0].total_count) : 0;
|
||||
|
||||
return NextResponse.json({
|
||||
period,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
tickets: res.rows.map((r) => ({
|
||||
ticketNumber: r.ticket_number,
|
||||
title: r.title,
|
||||
companyName: r.company_name,
|
||||
issueTypeLabel: r.issue_type_label,
|
||||
statusLabel: r.status_label,
|
||||
priorityLabel: r.priority_label,
|
||||
lastActivityDate: r.last_activity_date
|
||||
? r.last_activity_date.toISOString()
|
||||
: null,
|
||||
createDate: r.create_date ? r.create_date.toISOString() : null,
|
||||
latestAnalysisId: r.latest_analysis_id,
|
||||
latestAnalysisVersion: r.latest_analysis_version,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
|
@ -11,7 +11,13 @@ import {
|
|||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ChevronRight, ChevronDown, ExternalLink, AlertTriangle } from 'lucide-react';
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import { ShareModal } from './share-modal';
|
||||
import { AnalyzeButton } from './analyze-button';
|
||||
import type { PersistedAnalysis, Visibility } from '@/lib/types/analyzer';
|
||||
|
|
@ -38,6 +44,29 @@ const SEVERITY_TONE: Record<string, string> = {
|
|||
low: 'border-blue-500 bg-blue-500/5',
|
||||
};
|
||||
|
||||
function ProseText({
|
||||
text,
|
||||
className = '',
|
||||
}: {
|
||||
text: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const paragraphs = text
|
||||
.split(/\n\s*\n/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
if (paragraphs.length === 0) return null;
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
{paragraphs.map((p, i) => (
|
||||
<p key={i} className="leading-7 whitespace-pre-line">
|
||||
{p}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfidenceBadge({ score }: { score: number | null }) {
|
||||
if (score === null) return null;
|
||||
const pct = Math.round(score * 100);
|
||||
|
|
@ -135,42 +164,51 @@ export function AnalysisView({ analysis: a }: AnalysisViewProps) {
|
|||
{a.summary && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Summary</CardTitle>
|
||||
<CardTitle className="text-base text-muted-foreground uppercase tracking-wide font-semibold">
|
||||
Summary
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{a.summary}
|
||||
</p>
|
||||
<ProseText text={a.summary} className="text-base text-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 3. Next step */}
|
||||
{a.nextStep && (
|
||||
<Card className="border-primary/40">
|
||||
<Card className="border-primary/40 bg-primary/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
Next step
|
||||
<CardTitle className="text-base text-primary uppercase tracking-wide font-semibold flex items-center gap-2">
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
Recommended Next Step
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm font-medium">{a.nextStep}</p>
|
||||
<ProseText
|
||||
text={a.nextStep}
|
||||
className="text-base font-medium text-foreground"
|
||||
/>
|
||||
{a.nextStepRationale && (
|
||||
<Collapsible open={nextStepOpen} onOpenChange={setNextStepOpen} className="mt-3">
|
||||
<Collapsible
|
||||
open={nextStepOpen}
|
||||
onOpenChange={setNextStepOpen}
|
||||
className="mt-4 pt-4 border-t border-primary/20"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="-ml-3">
|
||||
<Button variant="ghost" size="sm" className="-ml-2 h-8">
|
||||
{nextStepOpen ? (
|
||||
<ChevronDown className="w-4 h-4 mr-1" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
Rationale
|
||||
{nextStepOpen ? 'Hide rationale' : 'Show rationale'}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{a.nextStepRationale}
|
||||
</p>
|
||||
<ProseText
|
||||
text={a.nextStepRationale}
|
||||
className="text-sm text-muted-foreground mt-3 pl-4 border-l-2 border-primary/30"
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
|
@ -308,12 +346,15 @@ export function AnalysisView({ analysis: a }: AnalysisViewProps) {
|
|||
{a.postResolutionAnalysis && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Post-resolution analysis</CardTitle>
|
||||
<CardTitle className="text-base text-muted-foreground uppercase tracking-wide font-semibold">
|
||||
Post-Resolution Analysis
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm whitespace-pre-wrap">
|
||||
{a.postResolutionAnalysis}
|
||||
</p>
|
||||
<ProseText
|
||||
text={a.postResolutionAnalysis}
|
||||
className="text-base text-foreground"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ import {
|
|||
SlidersHorizontal,
|
||||
GitCompare,
|
||||
Brain,
|
||||
Search,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
NavigationMenu,
|
||||
|
|
@ -106,6 +108,24 @@ const navigationItems: NavItem[] = [
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Analyzer',
|
||||
icon: Sparkles,
|
||||
children: [
|
||||
{
|
||||
title: 'Browse Tickets',
|
||||
href: '/analyzer/tickets',
|
||||
icon: Search,
|
||||
description: 'Filter tickets by period, client, or issue type — pick one to analyze',
|
||||
},
|
||||
{
|
||||
title: 'Needs Review',
|
||||
href: '/analyzer/queue',
|
||||
icon: AlertTriangle,
|
||||
description: 'Analyses flagged for human review (low confidence or cost-ceiling skipped Opus)',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Admin',
|
||||
icon: Activity,
|
||||
|
|
|
|||
|
|
@ -414,6 +414,85 @@ This file is updated after each phase ships.
|
|||
|
||||
---
|
||||
|
||||
## Phase 9 — Ticket browser + analysis-view formatting
|
||||
|
||||
Reactive to first-use feedback: the analysis page Summary / Next Step
|
||||
text was bare and dense, and there was no way to discover tickets to
|
||||
analyze without typing the URL.
|
||||
|
||||
**Delivered**
|
||||
|
||||
- `<ProseText>` helper inside `analysis-view.tsx` — splits text on blank
|
||||
lines, renders each chunk as a separate `<p>` with `leading-7` and
|
||||
`whitespace-pre-line`. Applied to Summary, Next Step, Next Step
|
||||
rationale, and Post-Resolution Analysis. Single-paragraph text still
|
||||
renders cleanly.
|
||||
- Summary + Post-Resolution headers got an uppercase tracking-wide
|
||||
treatment to act as section dividers, and body text bumped to
|
||||
`text-base text-foreground` so it reads as a finding rather than a
|
||||
caption.
|
||||
- Next Step card now has a subtle `bg-primary/5` tint, an `ArrowRight`
|
||||
icon next to "Recommended Next Step", a stronger separator before
|
||||
the rationale collapsible, and the rationale itself renders in a
|
||||
bordered indented block.
|
||||
- New `/analyzer/tickets` browse page — pill-style period chips
|
||||
(Today, Yesterday, This week, Last week, Last 30/60 days, All time),
|
||||
a client (company) Select, an issue-type Select, and a debounced
|
||||
free-text search across ticket_number/title. Compact table with
|
||||
per-row Analyze/Re-analyze button (reusing `<AnalyzeButton>`) and a
|
||||
"View" button shortcut to the existing analysis when one is
|
||||
recorded. Active-filter count + clear-all in the filter card header.
|
||||
- New API `GET /api/analyzer/tickets/list` — filters by `period`
|
||||
(computed in Postgres against `last_activity_date`), `companyId`,
|
||||
`issueType`, `search`. Returns 50 rows + total via `COUNT(*) OVER ()`,
|
||||
plus `latestAnalysisId` from a LATERAL join into `analyzer_analyses`.
|
||||
- New API `GET /api/analyzer/tickets/filter-options` — companies that
|
||||
have at least one non-deleted ticket (drops dormant accounts) +
|
||||
active issue types ordered by `sort_order, label`.
|
||||
- Top-level "Analyzer" nav menu added to `app-navigation.tsx`, with
|
||||
"Browse Tickets" + "Needs Review". Earlier phases left this off
|
||||
intentionally; this phase opts in.
|
||||
|
||||
**Decisions worth flagging**
|
||||
|
||||
- **Period filters on `last_activity_date`, not `create_date`.**
|
||||
"Today" surfaces tickets that had activity today (new tickets,
|
||||
re-opened, status churn) — much more useful for an analyzer-driven
|
||||
triage flow than tickets created today. A new ticket created today
|
||||
also has activity today, so we don't lose those.
|
||||
- **Period math runs in Postgres via `date_trunc('day', NOW())` etc.**
|
||||
Database server-clock = app-process clock for an internal Docker
|
||||
stack, so naive timestamps and naive `NOW()` agree. If users
|
||||
complain about edge-of-day drift, swap to
|
||||
`NOW() AT TIME ZONE 'America/New_York'` — Pulse's primary user base.
|
||||
- **Default period is `last_30d`.** "All time" pulls many thousands of
|
||||
rows; defaulting wide-open hurts first-page latency. 30 days hits
|
||||
~7K rows in our DB, paginates cleanly.
|
||||
- **Per-row analyze button reuses `<AnalyzeButton>` directly.** Each
|
||||
row gets its own component instance — no shared state, the running
|
||||
state lives per-button. The button navigates on completion, which
|
||||
feels right: click Analyze, watch the stages, land on the analysis
|
||||
page.
|
||||
- **`force=true` is set automatically on tickets that already have an
|
||||
analysis.** Re-analyze should re-run, not short-circuit to the
|
||||
cached row. The "View" button covers the cached path.
|
||||
- **No Linear/JIRA-style multi-select filters.** Single-value Selects
|
||||
are simpler and match the rest of Pulse.
|
||||
|
||||
**Deliberately left out**
|
||||
|
||||
- **No saved views.** A filter URL is shareable, but there's no
|
||||
bookmark / saved-view UX. Add when someone asks.
|
||||
- **No `latest_analysis_status` exposure.** A failed analysis doesn't
|
||||
show up — the LATERAL join filters by `status='complete'`. So a
|
||||
ticket whose only analysis failed looks like an un-analyzed ticket.
|
||||
Acceptable: re-running is the intended action there anyway.
|
||||
- **Search is `ILIKE '%...%'`.** No tsvector / trigram index. 7K-row
|
||||
scans are sub-100ms in this DB; if the corpus grows past low six
|
||||
digits, swap in `pg_trgm`.
|
||||
|
||||
---
|
||||
|
||||
## Status after each phase
|
||||
|
||||
| Phase | Tests | tsc | Notes |
|
||||
|
|
@ -426,3 +505,4 @@ This file is updated after each phase ships.
|
|||
| 6 | 128 | clean | frontend (no FE tests) |
|
||||
| 7 | 128 | clean | share email via existing SMTP transport |
|
||||
| 8 | 128 | clean | operator runbook + README link |
|
||||
| 9 | 128 | clean | browse page + analysis-view formatting + nav entry |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue