Phase 3b: Case Detail view with workflow timeline

New dynamic route /cases/[caseNumber] wired to the FastAPI details
endpoint, using Next 16's async params pattern with React's use()
hook. TanStack Query handles 5s refetchInterval so the page
self-updates during long-running processing without manual polling.

New components: CaseHeader (breadcrumb + meta), WorkflowTimeline
(5-phase RTL pipeline view), DocumentsPanel (categorized list).
Tabs split overview/documents/actions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 16:06:27 +00:00
parent 51064f3a03
commit d0daa0efe8
5 changed files with 367 additions and 1 deletions

View File

@@ -0,0 +1,67 @@
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { StatusBadge } from "@/components/cases/status-badge";
import type { CaseDetail } from "@/lib/api/cases";
function formatDate(iso?: string | null) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("he-IL", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return iso ?? "—";
}
}
export function CaseHeader({ data }: { data?: CaseDetail }) {
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<nav className="text-[0.78rem] text-ink-muted mb-3 flex items-center gap-2">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden>·</span>
<span>תיקי ערר</span>
<span aria-hidden>·</span>
<span className="text-navy tabular-nums">{data?.case_number ?? "…"}</span>
</nav>
<div className="flex items-start justify-between gap-6 flex-wrap">
<div className="space-y-2">
<div className="flex items-center gap-3">
<span className="font-display text-[2rem] font-black text-navy leading-none tabular-nums">
ערר {data?.case_number ?? "—"}
</span>
{data?.status && <StatusBadge status={data.status} />}
</div>
<h1 className="text-navy text-xl font-bold leading-snug max-w-2xl mb-0">
{data?.title ?? "טוען…"}
</h1>
{data?.subject && (
<p className="text-ink-muted text-sm max-w-2xl leading-relaxed">
{data.subject}
</p>
)}
</div>
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
תאריך דיון
</dt>
<dd className="text-ink-soft tabular-nums">{formatDate(data?.hearing_date)}</dd>
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
עודכן
</dt>
<dd className="text-ink-soft tabular-nums">{formatDate(data?.updated_at)}</dd>
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
ועדה
</dt>
<dd className="text-ink-soft">{data?.committee_type ?? "—"}</dd>
</dl>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,73 @@
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { CaseDetail } from "@/lib/api/cases";
function formatSize(bytes?: number) {
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function categoryTone(category?: string | null) {
switch (category) {
case "appeal": return "bg-info-bg text-info border-info/40";
case "response": return "bg-gold-wash text-gold-deep border-gold/40";
case "decision": return "bg-success-bg text-success border-success/40";
case "protocol": return "bg-warn-bg text-warn border-warn/40";
default: return "bg-rule-soft text-ink-muted border-rule";
}
}
export function DocumentsPanel({ data }: { data?: CaseDetail }) {
const docs = data?.documents ?? [];
if (docs.length === 0) {
return (
<div className="text-center py-12 text-ink-muted">
<div className="text-gold text-2xl mb-2" aria-hidden></div>
<p className="text-sm">אין מסמכים בתיק זה</p>
</div>
);
}
return (
<ScrollArea className="max-h-[520px]">
<ul className="divide-y divide-rule">
{docs.map((doc) => (
<li
key={doc.id}
className="py-3 flex items-start justify-between gap-4 hover:bg-gold-wash/30 transition-colors px-2 -mx-2 rounded"
>
<div className="flex-1 min-w-0 space-y-0.5">
<div className="text-ink font-medium truncate" title={doc.filename}>
{doc.filename}
</div>
<div className="text-[0.72rem] text-ink-muted flex items-center gap-3">
{doc.size_bytes && (
<span className="tabular-nums">{formatSize(doc.size_bytes)}</span>
)}
{doc.uploaded_at && (
<span>
{new Date(doc.uploaded_at).toLocaleDateString("he-IL")}
</span>
)}
{doc.status && doc.status !== "ready" && (
<span className="text-warn">{doc.status}</span>
)}
</div>
</div>
{doc.category && (
<Badge
variant="outline"
className={`rounded-full px-2 py-0.5 text-[0.7rem] ${categoryTone(doc.category)}`}
>
{doc.category}
</Badge>
)}
</li>
))}
</ul>
</ScrollArea>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
import type { CaseStatus } from "@/lib/api/cases";
import { STATUS_LABELS } from "@/components/cases/status-badge";
/*
* Vertical RTL workflow timeline showing the 13-status case pipeline.
* Groups the raw statuses into the 5 visual phases used across the app
* (intake → prep → thinking → writing → done) so the user sees
* "where am I in the process" rather than 13 micro-steps.
*/
type Phase = {
key: string;
label: string;
statuses: CaseStatus[];
};
const PHASES: Phase[] = [
{ key: "intake", label: "קליטה ועיבוד", statuses: ["new", "uploading", "processing"] },
{ key: "prep", label: "הכנת תיק", statuses: ["documents_ready", "outcome_set"] },
{ key: "thinking", label: "ניתוח וכיוון", statuses: ["brainstorming", "direction_approved"] },
{ key: "writing", label: "כתיבת טיוטה", statuses: ["drafting", "qa_review", "drafted"] },
{ key: "done", label: "סגירה", statuses: ["exported", "reviewed", "final"] },
];
function phaseIndexOf(status?: CaseStatus): number {
if (!status) return -1;
return PHASES.findIndex((p) => p.statuses.includes(status));
}
export function WorkflowTimeline({ status }: { status?: CaseStatus }) {
const currentIdx = phaseIndexOf(status);
return (
<ol className="relative space-y-4">
<div
className="absolute top-2 bottom-2 right-[11px] w-px bg-rule"
aria-hidden
/>
{PHASES.map((phase, i) => {
const state =
currentIdx === -1 ? "pending"
: i < currentIdx ? "done"
: i === currentIdx ? "current"
: "pending";
const dotTone =
state === "done" ? "bg-success border-success"
: state === "current" ? "bg-gold border-gold shadow-[0_0_0_4px_color-mix(in_oklab,var(--color-gold)_20%,transparent)]"
: "bg-surface border-rule";
const labelTone =
state === "done" ? "text-ink-soft"
: state === "current" ? "text-navy font-semibold"
: "text-ink-muted";
return (
<li key={phase.key} className="relative flex items-start gap-3 ps-7">
<span
className={`absolute right-[5px] top-1 inline-block w-3 h-3 rounded-full border-2 ${dotTone}`}
aria-hidden
/>
<div className="flex flex-col">
<span className={`text-sm ${labelTone}`}>{phase.label}</span>
{state === "current" && status && (
<span className="text-[0.72rem] text-gold-deep mt-0.5">
{STATUS_LABELS[status]}
</span>
)}
</div>
</li>
);
})}
</ol>
);
}