diff --git a/web-ui/src/app/cases/[caseNumber]/page.tsx b/web-ui/src/app/cases/[caseNumber]/page.tsx
new file mode 100644
index 0000000..96344d8
--- /dev/null
+++ b/web-ui/src/app/cases/[caseNumber]/page.tsx
@@ -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 (
+
+
+ {error ? (
+
+
+ שגיאה בטעינת התיק
+ {error.message}
+
+
+
+ ) : (
+ <>
+ {isPending ? (
+
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ סקירה
+
+ מסמכים
+ {data?.documents && (
+
+ ({data.documents.length})
+
+ )}
+
+ פעולות
+
+
+
+
+
תוצאה צפויה
+
+ {data?.expected_outcome ?? "לא נקבעה תוצאה צפויה."}
+
+
+
+
סיכום מהיר
+
+ - מסמכים בתיק
+ -
+ {data?.documents?.length ?? 0}
+
+ - בעיבוד
+ -
+ {data?.processing_count ?? 0}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ מעבר לעורך 12 הבלוקים לכתיבת טיוטה.
+
+
+
+
+
+
+
+
+
+ שלב בתהליך
+
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/web-ui/src/components/cases/case-header.tsx b/web-ui/src/components/cases/case-header.tsx
new file mode 100644
index 0000000..8966990
--- /dev/null
+++ b/web-ui/src/components/cases/case-header.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ ערר {data?.case_number ?? "—"}
+
+ {data?.status && }
+
+
+ {data?.title ?? "טוען…"}
+
+ {data?.subject && (
+
+ {data.subject}
+
+ )}
+
+
+
+ -
+ תאריך דיון
+
+ - {formatDate(data?.hearing_date)}
+ -
+ עודכן
+
+ - {formatDate(data?.updated_at)}
+ -
+ ועדה
+
+ - {data?.committee_type ?? "—"}
+
+
+
+
+ );
+}
diff --git a/web-ui/src/components/cases/documents-panel.tsx b/web-ui/src/components/cases/documents-panel.tsx
new file mode 100644
index 0000000..fa94ead
--- /dev/null
+++ b/web-ui/src/components/cases/documents-panel.tsx
@@ -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 (
+
+
❦
+
אין מסמכים בתיק זה
+
+ );
+ }
+
+ return (
+
+
+ {docs.map((doc) => (
+ -
+
+
+ {doc.filename}
+
+
+ {doc.size_bytes && (
+ {formatSize(doc.size_bytes)}
+ )}
+ {doc.uploaded_at && (
+
+ {new Date(doc.uploaded_at).toLocaleDateString("he-IL")}
+
+ )}
+ {doc.status && doc.status !== "ready" && (
+ {doc.status}
+ )}
+
+
+ {doc.category && (
+
+ {doc.category}
+
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/web-ui/src/components/cases/workflow-timeline.tsx b/web-ui/src/components/cases/workflow-timeline.tsx
new file mode 100644
index 0000000..ffeaf73
--- /dev/null
+++ b/web-ui/src/components/cases/workflow-timeline.tsx
@@ -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 (
+
+
+ {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 (
+ -
+
+
+ {phase.label}
+ {state === "current" && status && (
+
+ {STATUS_LABELS[status]}
+
+ )}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/web-ui/src/lib/api/cases.ts b/web-ui/src/lib/api/cases.ts
index d4bedb4..7a68f58 100644
--- a/web-ui/src/lib/api/cases.ts
+++ b/web-ui/src/lib/api/cases.ts
@@ -74,10 +74,34 @@ export function useCase(caseNumber: string | undefined) {
return useQuery({
queryKey: casesKeys.detail(caseNumber ?? ""),
queryFn: ({ signal }) =>
- apiRequest(`/api/cases/${caseNumber}`, { signal }),
+ apiRequest(`/api/cases/${caseNumber}/details`, { signal }),
enabled: Boolean(caseNumber),
/* Replaces the old 5s polling from vanilla index.html */
staleTime: 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(`/api/cases/${caseNumber}/status`, { signal }),
+ enabled: Boolean(caseNumber),
+ staleTime: 5_000,
+ refetchInterval: 5_000,
+ });
+}