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:
lorentz 2026-04-29 13:25:16 -04:00
parent 966376e6b6
commit b20c94ea1a
6 changed files with 879 additions and 19 deletions

View file

@ -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>
)}