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:
125
web-ui/src/app/cases/[caseNumber]/page.tsx
Normal file
125
web-ui/src/app/cases/[caseNumber]/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { use } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { CaseHeader } from "@/components/cases/case-header";
|
||||||
|
import { WorkflowTimeline } from "@/components/cases/workflow-timeline";
|
||||||
|
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||||
|
import { useCase } from "@/lib/api/cases";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Next 16 breaking change: route params are now a Promise.
|
||||||
|
* The `use()` hook unwraps them inside a client component.
|
||||||
|
*/
|
||||||
|
export default function CaseDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ caseNumber: string }>;
|
||||||
|
}) {
|
||||||
|
const { caseNumber } = use(params);
|
||||||
|
const { data, isPending, error } = useCase(caseNumber);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<section className="space-y-6">
|
||||||
|
{error ? (
|
||||||
|
<Card className="bg-danger-bg border-danger/40">
|
||||||
|
<CardContent className="px-6 py-6 text-center space-y-3">
|
||||||
|
<p className="text-danger font-semibold">שגיאה בטעינת התיק</p>
|
||||||
|
<p className="text-sm text-ink-muted">{error.message}</p>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/">חזרה לרשימת התיקים</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isPending ? (
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5 space-y-3">
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-6 w-96" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<CaseHeader data={data} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1fr_280px]">
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<Tabs defaultValue="overview" dir="rtl">
|
||||||
|
<TabsList className="bg-rule-soft/60">
|
||||||
|
<TabsTrigger value="overview">סקירה</TabsTrigger>
|
||||||
|
<TabsTrigger value="documents">
|
||||||
|
מסמכים
|
||||||
|
{data?.documents && (
|
||||||
|
<span className="ms-1.5 text-[0.7rem] text-ink-muted tabular-nums">
|
||||||
|
({data.documents.length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="actions">פעולות</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="overview" className="mt-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-navy text-base mb-2">תוצאה צפויה</h3>
|
||||||
|
<p className="text-ink-soft text-sm leading-relaxed">
|
||||||
|
{data?.expected_outcome ?? "לא נקבעה תוצאה צפויה."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-navy text-base mb-2">סיכום מהיר</h3>
|
||||||
|
<dl className="grid grid-cols-2 gap-y-2 gap-x-6 text-sm">
|
||||||
|
<dt className="text-ink-muted">מסמכים בתיק</dt>
|
||||||
|
<dd className="text-ink tabular-nums">
|
||||||
|
{data?.documents?.length ?? 0}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-ink-muted">בעיבוד</dt>
|
||||||
|
<dd className="text-ink tabular-nums">
|
||||||
|
{data?.processing_count ?? 0}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="documents" className="mt-5">
|
||||||
|
<DocumentsPanel data={data} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="actions" className="mt-5">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
|
||||||
|
<Link href={`/cases/${caseNumber}/compose`}>
|
||||||
|
פתח בעורך ההחלטה
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-ink-muted">
|
||||||
|
מעבר לעורך 12 הבלוקים לכתיבת טיוטה.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-surface border-rule shadow-sm h-fit">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<h2 className="text-navy text-base mb-4">שלב בתהליך</h2>
|
||||||
|
<WorkflowTimeline status={data?.status} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
web-ui/src/components/cases/case-header.tsx
Normal file
67
web-ui/src/components/cases/case-header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
web-ui/src/components/cases/documents-panel.tsx
Normal file
73
web-ui/src/components/cases/documents-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
web-ui/src/components/cases/workflow-timeline.tsx
Normal file
77
web-ui/src/components/cases/workflow-timeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -74,10 +74,34 @@ export function useCase(caseNumber: string | undefined) {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: casesKeys.detail(caseNumber ?? ""),
|
queryKey: casesKeys.detail(caseNumber ?? ""),
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
apiRequest<CaseDetail>(`/api/cases/${caseNumber}`, { signal }),
|
apiRequest<CaseDetail>(`/api/cases/${caseNumber}/details`, { signal }),
|
||||||
enabled: Boolean(caseNumber),
|
enabled: Boolean(caseNumber),
|
||||||
/* Replaces the old 5s polling from vanilla index.html */
|
/* Replaces the old 5s polling from vanilla index.html */
|
||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
refetchInterval: 5_000,
|
refetchInterval: 5_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WorkflowStatus = {
|
||||||
|
case_number?: string;
|
||||||
|
status?: CaseStatus | string;
|
||||||
|
current_step?: string;
|
||||||
|
steps?: Array<{
|
||||||
|
key: string;
|
||||||
|
label?: string;
|
||||||
|
status: "done" | "current" | "pending" | string;
|
||||||
|
}>;
|
||||||
|
/* FastAPI returns free-form JSON; keep it permissive */
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useWorkflowStatus(caseNumber: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [...casesKeys.all, "workflow", caseNumber ?? ""] as const,
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<WorkflowStatus>(`/api/cases/${caseNumber}/status`, { signal }),
|
||||||
|
enabled: Boolean(caseNumber),
|
||||||
|
staleTime: 5_000,
|
||||||
|
refetchInterval: 5_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user