feat(06-03): add mobile analyzer detail page /mobile/analyzer/[id]
- Real segment route reading GET /api/analyzer/analyses/[id] (D-25, reused as-is)
- Three content sections: Summary / Next Step / Next Step Rationale (D-21, ANL-03)
- Identity block: ticket# badge, completed-at relative time, stage pips, confidence badge, Review pill
- Header: back chevron (router.back()) + breadcrumb 'Analyzer / #{ticketNumber}' + external link (D-19)
- Footer: 'View full analysis' link to /analyzer/analysis/[id] with ExternalLink icon, min-h-[44px] (D-22, ANL-04)
- Read-only enforcement: zero form/edit/re-run/cancel controls (D-23, ANL-05)
- Loading skeleton, 404 state, error state with toast (D-28)
- Title/company omitted from identity block per D-25/D-36 (PersistedAnalysis lacks those fields)
This commit is contained in:
parent
86369bd6ab
commit
aa4ff00065
1 changed files with 253 additions and 0 deletions
253
app/mobile/analyzer/[id]/page.tsx
Normal file
253
app/mobile/analyzer/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
'use client';
|
||||
|
||||
/* MobileAnalyzerDetailPage — phase 06 (ANL-03, ANL-04, ANL-05).
|
||||
* Purpose: Per-analysis summary view at /mobile/analyzer/[id].
|
||||
* Reads from GET /api/analyzer/analyses/[id] (D-25, reused as-is).
|
||||
* Renders Summary / Next Step / Next Step Rationale (D-21).
|
||||
* Provides "View full analysis" footer link to desktop (D-22).
|
||||
* Read-only — no edit, re-run, cancel, or prompt-tuning controls (D-23, ANL-05).
|
||||
* Note: Title and company name are NOT rendered — PersistedAnalysis does not include
|
||||
* those fields (they live on the tickets/companies tables). D-25/D-36 forbid
|
||||
* modifying the existing endpoint. Breadcrumb conveys ticket identity (D-19). */
|
||||
|
||||
import { useEffect, useState, use } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, ExternalLink } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { AnalyzerStagePips } from '@/components/mobile/AnalyzerStagePips';
|
||||
import { ConfidenceBadge } from '@/components/mobile/ConfidenceBadge';
|
||||
import type { PersistedAnalysis } from '@/lib/types/analyzer';
|
||||
|
||||
function relTime(ts: string | null): string {
|
||||
if (!ts) return '—';
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
interface DetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function MobileAnalyzerDetailPage({ params }: DetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
|
||||
const [analysis, setAnalysis] = useState<PersistedAnalysis | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setNotFound(false);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await fetch(`/api/analyzer/analyses/${encodeURIComponent(id)}`);
|
||||
if (r.status === 404) {
|
||||
if (!cancelled) {
|
||||
setNotFound(true);
|
||||
setAnalysis(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const data = await r.json();
|
||||
if (!cancelled) setAnalysis(data.analysis as PersistedAnalysis);
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to load analysis';
|
||||
setError(msg);
|
||||
toast.error('Failed to load analysis');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => { cancelled = true; };
|
||||
}, [id]);
|
||||
|
||||
// ──── In-page header (D-19) — back chevron + breadcrumb + external link ────
|
||||
const header = (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
aria-label="Back to Analyzer"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
<span>Analyzer</span>
|
||||
</button>
|
||||
<span className="text-sm font-semibold truncate max-w-[55%] text-center">
|
||||
{analysis ? `Analyzer / #${analysis.ticketNumber}` : 'Analyzer'}
|
||||
</span>
|
||||
<a
|
||||
href={`/analyzer/analysis/${id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Open full analysis on desktop"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ──── Loading skeleton (D-28 / UI-SPEC "Detail page loading") ────
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<div className="px-4 pt-4 pb-2 space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
</div>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<section key={i} className="px-4 py-4 space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-5/6" />
|
||||
<Skeleton className="h-3 w-4/6" />
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──── 404 state ────
|
||||
if (notFound) {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<div className="px-4 py-12 text-center space-y-3">
|
||||
<p className="text-sm text-muted-foreground">Analysis not found</p>
|
||||
<a href="/mobile/analyzer" className="text-sm text-primary hover:underline">
|
||||
Back to feed
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──── Error state ────
|
||||
if (error || !analysis) {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<div className="px-4 py-12 text-center space-y-3">
|
||||
<p className="text-sm text-destructive">{error ?? 'Failed to load analysis'}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setError(null); setLoading(true); }}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──── Loaded — full render ────
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
|
||||
{/* Identity block (D-20 — adjusted: no title/company per interfaces note) */}
|
||||
<div className="px-4 pt-4 pb-2 space-y-1">
|
||||
<span className="text-[10px] font-mono bg-muted rounded px-1.5 py-0.5 inline-block">
|
||||
{analysis.ticketNumber}
|
||||
</span>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{relTime(analysis.completedAt)}
|
||||
</p>
|
||||
<div className="flex gap-2 items-center mt-1">
|
||||
<AnalyzerStagePips
|
||||
haikuUsed={analysis.haikuUsed}
|
||||
sonnetUsed={analysis.sonnetUsed}
|
||||
opusUsed={analysis.opusUsed}
|
||||
/>
|
||||
<ConfidenceBadge score={analysis.confidenceScore} />
|
||||
{analysis.needsHumanReview && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0.5 border-0 bg-destructive/10 text-destructive"
|
||||
aria-label="Needs human review"
|
||||
>
|
||||
Review
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Section 1 — Summary (D-21) */}
|
||||
<section className="px-4 py-4 space-y-2">
|
||||
<h2 className="text-sm font-semibold">Summary</h2>
|
||||
{analysis.summary ? (
|
||||
<p className="text-sm font-normal leading-relaxed text-foreground whitespace-pre-wrap">
|
||||
{analysis.summary}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Summary not available.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Section 2 — Next Step (D-21) */}
|
||||
<section className="px-4 py-4 space-y-2">
|
||||
<h2 className="text-sm font-semibold">Next Step</h2>
|
||||
{analysis.nextStep ? (
|
||||
<p className="text-sm font-normal leading-relaxed text-foreground whitespace-pre-wrap">
|
||||
{analysis.nextStep}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Next step not available.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Section 3 — Next Step Rationale (D-21) */}
|
||||
<section className="px-4 py-4 space-y-2">
|
||||
<h2 className="text-sm font-semibold">Next Step Rationale</h2>
|
||||
{analysis.nextStepRationale ? (
|
||||
<p className="text-sm font-normal leading-relaxed text-foreground whitespace-pre-wrap">
|
||||
{analysis.nextStepRationale}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Rationale not available.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Footer link (D-22) — "View full analysis" → desktop */}
|
||||
<div className="px-4 py-4 border-t">
|
||||
<a
|
||||
href={`/analyzer/analysis/${id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-sm font-semibold text-primary hover:underline min-h-[44px]"
|
||||
>
|
||||
View full analysis
|
||||
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue