Phase 3a: shadcn init + Home/Case List view

Initialize shadcn/ui (radix-nova preset) and wire its semantic tokens
to the editorial navy/cream/gold palette so primitives inherit the
judicial voice without per-component overrides.

Replace the Phase 2 live-probe with a real dashboard: KPI tiles,
conic-gradient status donut (ported from the vanilla renderHero),
and a TanStack Table cases list with search + sort. Add useCase(n)
hook with 5s staleTime/refetchInterval to replace the old manual
polling loop when Case Detail ships next.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 16:04:30 +00:00
parent 0ee8e723bd
commit 51064f3a03
25 changed files with 6513 additions and 247 deletions

View File

@@ -1,51 +0,0 @@
"use client";
import { useCases } from "@/lib/api/cases";
/**
* Phase 2 smoke-test component — proves that the typed API client, the
* TanStack Query provider, the Next.js rewrite proxy, and the FastAPI backend
* are all wired up correctly end-to-end. Temporary; replaced in Phase 3 by the
* real case list view.
*/
export function CasesLiveProbe() {
const { data, isLoading, isError, error } = useCases();
if (isLoading) {
return (
<p className="text-ink-muted">טוען תיקים מה-API</p>
);
}
if (isError) {
return (
<div className="text-danger">
<p className="font-medium">שגיאה בטעינת תיקים</p>
<p className="text-sm text-ink-muted">
{error instanceof Error ? error.message : "שגיאה לא ידועה"}
</p>
</div>
);
}
const count = data?.length ?? 0;
return (
<div className="space-y-2">
<p className="text-ink">
<span className="font-semibold text-navy">{count}</span>{" "}
תיקים טעונים מה-API בהצלחה.
</p>
{data && data.length > 0 && (
<ul className="text-sm text-ink-muted space-y-1">
{data.slice(0, 3).map((c) => (
<li key={c.case_number}>
<span className="text-gold-deep">{c.case_number}</span> {c.title}
</li>
))}
{data.length > 3 && <li>ועוד {data.length - 3}</li>}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,191 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type SortingState,
} from "@tanstack/react-table";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { StatusBadge } from "@/components/cases/status-badge";
import type { Case } from "@/lib/api/cases";
function formatDate(iso?: string) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("he-IL", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return iso;
}
}
const columns: ColumnDef<Case>[] = [
{
accessorKey: "case_number",
header: "מס׳ ערר",
cell: ({ row }) => (
<Link
href={`/cases/${row.original.case_number}`}
className="text-navy font-semibold hover:text-gold-deep tabular-nums"
>
{row.original.case_number}
</Link>
),
},
{
accessorKey: "title",
header: "כותרת",
cell: ({ row }) => (
<div className="text-ink max-w-[420px] truncate" title={row.original.title}>
{row.original.title}
</div>
),
},
{
accessorKey: "status",
header: "סטטוס",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
accessorKey: "document_count",
header: "מסמכים",
cell: ({ row }) => (
<span className="tabular-nums text-ink-soft">
{row.original.document_count ?? "—"}
</span>
),
},
{
accessorKey: "updated_at",
header: "עודכן",
cell: ({ row }) => (
<span className="text-ink-muted text-sm">{formatDate(row.original.updated_at)}</span>
),
},
];
export function CasesTable({
cases,
loading,
error,
}: {
cases?: Case[];
loading?: boolean;
error?: Error | null;
}) {
const [sorting, setSorting] = useState<SortingState>([
{ id: "updated_at", desc: true },
]);
const [globalFilter, setGlobalFilter] = useState("");
const data = useMemo(() => cases ?? [], [cases]);
const table = useReactTable({
data,
columns,
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
globalFilterFn: (row, _colId, filterValue: string) => {
if (!filterValue) return true;
const needle = filterValue.toLowerCase();
return (
row.original.case_number.toLowerCase().includes(needle) ||
row.original.title.toLowerCase().includes(needle)
);
},
});
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<Input
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder="חיפוש לפי מס׳ ערר או כותרת…"
className="max-w-sm bg-surface"
dir="rtl"
/>
<span className="text-sm text-ink-muted me-auto">
{table.getFilteredRowModel().rows.length} תיקים
</span>
</div>
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-rule-soft/60">
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id} className="border-rule">
{hg.headers.map((header) => (
<TableHead
key={header.id}
onClick={header.column.getToggleSortingHandler()}
className="text-navy font-semibold cursor-pointer select-none text-right"
>
{flexRender(header.column.columnDef.header, header.getContext())}
{{ asc: " ▲", desc: " ▼" }[header.column.getIsSorted() as string] ?? ""}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{loading ? (
Array.from({ length: 4 }).map((_, i) => (
<TableRow key={i} className="border-rule">
{columns.map((_c, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-24" />
</TableCell>
))}
</TableRow>
))
) : error ? (
<TableRow>
<TableCell colSpan={columns.length} className="text-center text-danger py-8">
שגיאה בטעינת תיקים: {error.message}
</TableCell>
</TableRow>
) : table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} className="text-center text-ink-muted py-12">
<div className="text-gold text-2xl mb-2" aria-hidden></div>
{globalFilter ? "אין תיקים תואמים לחיפוש" : "עדיין אין תיקי ערר"}
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
className="border-rule hover:bg-gold-wash/40 transition-colors"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-3">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { Card, CardContent } from "@/components/ui/card";
import type { Case } from "@/lib/api/cases";
type Bucket = {
label: string;
caption: string;
value: number;
tone: "navy" | "gold" | "warn" | "success";
};
const TONE_STYLES: Record<Bucket["tone"], string> = {
navy: "before:bg-navy text-navy",
gold: "before:bg-gold text-gold-deep",
warn: "before:bg-warn text-warn",
success: "before:bg-success text-success",
};
function bucketize(cases: Case[] | undefined): Bucket[] {
const c = cases ?? [];
const inProgress = c.filter((x) =>
["processing", "documents_ready", "outcome_set", "brainstorming", "direction_approved"].includes(x.status),
).length;
const drafting = c.filter((x) =>
["drafting", "qa_review", "drafted"].includes(x.status),
).length;
const done = c.filter((x) =>
["exported", "reviewed", "final"].includes(x.status),
).length;
return [
{ label: "סה״כ תיקי ערר", caption: "בכל הסטטוסים", value: c.length, tone: "navy" },
{ label: "בהכנה", caption: "מסמכים וניתוח", value: inProgress, tone: "gold" },
{ label: "בכתיבה", caption: "טיוטות ו-QA", value: drafting, tone: "warn" },
{ label: "מוכנים", caption: "יוצאו או סופיים", value: done, tone: "success" },
];
}
export function KPICards({ cases, loading }: { cases?: Case[]; loading?: boolean }) {
const buckets = bucketize(cases);
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{buckets.map((b) => (
<Card
key={b.label}
className={`
relative overflow-hidden bg-surface shadow-sm border-rule
before:content-[''] before:absolute before:top-0 before:right-0 before:h-full before:w-[3px]
${TONE_STYLES[b.tone]}
`}
>
<CardContent className="px-5 py-4 flex flex-col gap-1">
<span className="text-[0.72rem] uppercase tracking-[0.08em] text-ink-muted">
{b.label}
</span>
<span className="font-display text-[2.3rem] font-black leading-none">
{loading ? "—" : b.value}
</span>
<span className="text-[0.78rem] text-ink-muted mt-0.5">{b.caption}</span>
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { Badge } from "@/components/ui/badge";
import type { CaseStatus } from "@/lib/api/cases";
const STATUS_LABELS: Record<CaseStatus, string> = {
new: "חדש",
uploading: "מעלה",
processing: "בעיבוד",
documents_ready: "מסמכים מוכנים",
outcome_set: "תוצאה נקבעה",
brainstorming: "סיעור מוחות",
direction_approved: "כיוון אושר",
drafting: "בכתיבה",
qa_review: "QA",
drafted: "טיוטה",
exported: "יוצא",
reviewed: "נבדק",
final: "סופי",
};
/* Status color groups:
* intake → new, uploading, processing (muted parchment)
* prep → documents_ready, outcome_set (info blue)
* thinking→ brainstorming, direction_approved (gold)
* writing → drafting, qa_review, drafted (warn amber)
* done → exported, reviewed, final (success green) */
const STATUS_TONE: Record<CaseStatus, string> = {
new: "bg-rule-soft text-ink-muted border-rule",
uploading: "bg-rule-soft text-ink-muted border-rule",
processing: "bg-info-bg text-info border-info/30",
documents_ready: "bg-info-bg text-info border-info/40",
outcome_set: "bg-info-bg text-info border-info/40",
brainstorming: "bg-gold-wash text-gold-deep border-gold/40",
direction_approved:"bg-gold-wash text-gold-deep border-gold/50",
drafting: "bg-warn-bg text-warn border-warn/40",
qa_review: "bg-warn-bg text-warn border-warn/40",
drafted: "bg-warn-bg text-warn border-warn/50",
exported: "bg-success-bg text-success border-success/40",
reviewed: "bg-success-bg text-success border-success/50",
final: "bg-success-bg text-success border-success/60 font-semibold",
};
export function StatusBadge({ status }: { status: CaseStatus }) {
return (
<Badge
variant="outline"
className={`rounded-full px-2.5 py-0.5 text-[0.72rem] font-medium ${STATUS_TONE[status] ?? ""}`}
>
{STATUS_LABELS[status] ?? status}
</Badge>
);
}
export { STATUS_LABELS };

View File

@@ -0,0 +1,91 @@
"use client";
import type { Case, CaseStatus } from "@/lib/api/cases";
import { STATUS_LABELS } from "@/components/cases/status-badge";
/*
* Conic-gradient donut — ported from legal-ai/web/static/index.html renderHero().
* Kept deliberately dependency-free (no D3/recharts) — a single background-image.
* Five status groups map onto the navy/gold/info/warn/success palette.
*/
type GroupKey = "intake" | "prep" | "thinking" | "writing" | "done";
const GROUP_OF: Record<CaseStatus, GroupKey> = {
new: "intake", uploading: "intake", processing: "intake",
documents_ready: "prep", outcome_set: "prep",
brainstorming: "thinking", direction_approved: "thinking",
drafting: "writing", qa_review: "writing", drafted: "writing",
exported: "done", reviewed: "done", final: "done",
};
const GROUP_META: Record<GroupKey, { label: string; color: string }> = {
intake: { label: "חדש / בעיבוד", color: "var(--color-ink-muted)" },
prep: { label: "הכנה", color: "var(--color-info)" },
thinking: { label: "ניתוח וכיוון", color: "var(--color-gold)" },
writing: { label: "בכתיבה", color: "var(--color-warn)" },
done: { label: "מוכן", color: "var(--color-success)" },
};
export function StatusDonut({ cases }: { cases?: Case[] }) {
const counts: Record<GroupKey, number> = {
intake: 0, prep: 0, thinking: 0, writing: 0, done: 0,
};
(cases ?? []).forEach((c) => {
const g = GROUP_OF[c.status];
if (g) counts[g] += 1;
});
const total = Object.values(counts).reduce((a, b) => a + b, 0);
const segments: { key: GroupKey; start: number; end: number }[] = [];
let pct = 0;
(Object.keys(counts) as GroupKey[]).forEach((k) => {
if (counts[k] === 0) return;
const start = total === 0 ? 0 : (pct / total) * 360;
pct += counts[k];
const end = total === 0 ? 360 : (pct / total) * 360;
segments.push({ key: k, start, end });
});
const background =
total === 0
? "conic-gradient(var(--color-rule-soft) 0deg 360deg)"
: `conic-gradient(${segments
.map((s) => `${GROUP_META[s.key].color} ${s.start}deg ${s.end}deg`)
.join(", ")})`;
return (
<div className="flex items-center gap-6">
<div
className="relative w-[140px] h-[140px] rounded-full shadow-sm"
style={{ background }}
aria-label="פיזור תיקים לפי סטטוס"
>
<div className="absolute inset-[18px] bg-surface rounded-full flex flex-col items-center justify-center">
<span className="font-display text-2xl font-black text-navy leading-none">
{total}
</span>
<span className="text-[0.7rem] text-ink-muted mt-1">תיקים</span>
</div>
</div>
<ul className="flex flex-col gap-1.5 text-sm">
{(Object.keys(GROUP_META) as GroupKey[]).map((k) => (
<li key={k} className="flex items-center gap-2">
<span
className="inline-block w-2.5 h-2.5 rounded-full"
style={{ background: GROUP_META[k].color }}
/>
<span className="text-ink-soft">{GROUP_META[k].label}</span>
<span className="text-ink-muted tabular-nums me-auto ms-1">
{counts[k]}
</span>
</li>
))}
</ul>
</div>
);
}
/* Exported for the legend tests / docs if ever needed */
export { STATUS_LABELS };