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:
@@ -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>
|
||||
);
|
||||
}
|
||||
191
web-ui/src/components/cases/cases-table.tsx
Normal file
191
web-ui/src/components/cases/cases-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
web-ui/src/components/cases/kpi-cards.tsx
Normal file
65
web-ui/src/components/cases/kpi-cards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
web-ui/src/components/cases/status-badge.tsx
Normal file
53
web-ui/src/components/cases/status-badge.tsx
Normal 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 };
|
||||
91
web-ui/src/components/cases/status-donut.tsx
Normal file
91
web-ui/src/components/cases/status-donut.tsx
Normal 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 };
|
||||
Reference in New Issue
Block a user