feat(case-ui): redesign אימות-פסיקה tab + overview proportions (X17 #4) #376
@@ -149,8 +149,8 @@ export default function CaseDetailPage({
|
||||
StatusRail (banner A, X17), so every tab keeps the status context. */}
|
||||
<div className="min-w-0 mt-6">
|
||||
<TabsContent value="overview" className="mt-0">
|
||||
{/* documents + agents two-column (mockup 18j) */}
|
||||
<div className="grid gap-6 lg:grid-cols-2 items-start">
|
||||
{/* documents (wider) + agent-activity preview (mockup 18j) */}
|
||||
<div className="grid gap-6 lg:grid-cols-[1.4fr_1fr] items-start">
|
||||
<DocumentsPanel data={data} />
|
||||
<AgentActivityPreview caseNumber={caseNumber} />
|
||||
</div>
|
||||
|
||||
@@ -5,12 +5,22 @@
|
||||
* supporting precedents with the cumulative authority signal (cited_by), a verify
|
||||
* gate + chair note, and per-issue radar (unlinked digests). The writer cites only
|
||||
* verified quotes (INV-AH); the digest is never cited (INV-DIG1, radar only).
|
||||
*
|
||||
* Redesign (mockup 18g): summary + verification progress bar, status filters,
|
||||
* collapsible per-argument accordion with a verify-progress chip, and compact
|
||||
* precedent rows (clamped quote, collapsible chair note) — easier to triage.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import {
|
||||
useCitationVerification,
|
||||
useVerifyCitation,
|
||||
@@ -33,10 +43,34 @@ const RADAR_LABEL: Record<string, string> = {
|
||||
available_link: "בקורפוס — לקשר",
|
||||
};
|
||||
|
||||
type FilterKey = "all" | "pending" | "verified" | "radar";
|
||||
const FILTERS: { key: FilterKey; label: string }[] = [
|
||||
{ key: "all", label: "הכל" },
|
||||
{ key: "pending", label: "ממתין לאימות" },
|
||||
{ key: "verified", label: "מאומת" },
|
||||
{ key: "radar", label: "עם רדאר" },
|
||||
];
|
||||
|
||||
export function CitationVerificationPanel({ caseNumber }: { caseNumber: string }) {
|
||||
const { data, isPending, isError } = useCitationVerification(caseNumber);
|
||||
const verify = useVerifyCitation(caseNumber);
|
||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||
const [openNotes, setOpenNotes] = useState<Set<string>>(new Set());
|
||||
const [expandedQuotes, setExpandedQuotes] = useState<Set<string>>(new Set());
|
||||
const [filter, setFilter] = useState<FilterKey>("all");
|
||||
const [onlyUnverified, setOnlyUnverified] = useState(false);
|
||||
|
||||
function toggleSet(
|
||||
setter: React.Dispatch<React.SetStateAction<Set<string>>>,
|
||||
id: string,
|
||||
) {
|
||||
setter((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
@@ -111,76 +145,178 @@ export function CitationVerificationPanel({ caseNumber }: { caseNumber: string }
|
||||
}
|
||||
|
||||
const sum = data.summary;
|
||||
const args = data.arguments;
|
||||
|
||||
// verification progress across all supporting precedents
|
||||
let totalSup = 0;
|
||||
let verifiedSup = 0;
|
||||
for (const a of args) {
|
||||
totalSup += a.supporting.length;
|
||||
verifiedSup += a.supporting.filter((s) => s.verified).length;
|
||||
}
|
||||
const pendingSup = totalSup - verifiedSup;
|
||||
const okPct = totalSup ? Math.round((verifiedSup / totalSup) * 100) : 0;
|
||||
const supPct = totalSup ? Math.round((pendingSup / totalSup) * 100) : 0;
|
||||
const noSupportCount = sum.arguments_total - sum.arguments_with_support;
|
||||
|
||||
const argVerified = (a: ArgumentBlock) =>
|
||||
a.supporting.filter((s) => s.verified).length;
|
||||
const argHasPending = (a: ArgumentBlock) =>
|
||||
a.supporting.some((s) => !s.verified);
|
||||
|
||||
const shownArgs = args.filter((a) => {
|
||||
if (filter === "pending") return argHasPending(a);
|
||||
if (filter === "verified") return argVerified(a) > 0;
|
||||
if (filter === "radar") return a.radar.length > 0;
|
||||
return true;
|
||||
});
|
||||
|
||||
// default-open the actionable arguments (those with unverified support)
|
||||
const defaultOpen = args.filter(argHasPending).map((a) => a.argument_id);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* summary */}
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-ink-soft bg-parchment border border-rule rounded-lg px-4 py-2.5">
|
||||
<span>סוגיות עם פסיקה: <b className="text-navy tabular-nums">{sum.arguments_with_support}/{sum.arguments_total}</b></span>
|
||||
<span>אומתו: <b className="text-success tabular-nums">{sum.verified}</b></span>
|
||||
<span>לידֵי רדאר: <b className="text-warn tabular-nums">{sum.radar_leads}</b></span>
|
||||
<span className="text-ink-muted text-[0.78rem] ms-auto">ה-writer מצטט רק מאומתים (INV-AH) · היומון אינו מצוטט (INV-DIG1)</span>
|
||||
{/* summary + progress */}
|
||||
<div className="rounded-xl border border-rule bg-surface shadow-sm px-5 py-4">
|
||||
<div className="flex items-center gap-6 flex-wrap">
|
||||
<Stat k="סוגיות עם פסיקה" v={`${sum.arguments_with_support}/${sum.arguments_total}`} />
|
||||
<Stat k="אומתו" v={String(sum.verified)} tone="text-success" />
|
||||
<Stat k="לידֵי רדאר" v={String(sum.radar_leads)} tone="text-warn" />
|
||||
<span className="ms-auto text-[0.72rem] text-ink-muted max-w-[280px] leading-snug">
|
||||
ה-writer מצטט רק מאומתים (INV-AH) · היומון אינו מצוטט (INV-DIG1)
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3.5">
|
||||
<div className="h-2 rounded-full bg-rule-soft overflow-hidden flex">
|
||||
<span className="bg-success h-full" style={{ width: `${okPct}%` }} />
|
||||
<span className="bg-gold-soft h-full" style={{ width: `${supPct}%` }} />
|
||||
</div>
|
||||
<div className="flex gap-4 mt-1.5 text-[0.7rem] text-ink-muted flex-wrap">
|
||||
<span><Dot className="bg-success" /><b className="text-ink-soft">{verifiedSup} מאומתים</b></span>
|
||||
<span><Dot className="bg-gold-soft" /><b className="text-ink-soft">{pendingSup} ממתינים לאימות</b></span>
|
||||
<span><Dot className="bg-rule-soft" />{noSupportCount} סוגיות ללא פסיקה תומכת</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.arguments.map((a) => (
|
||||
<Card key={a.argument_id} className="bg-surface border-rule shadow-sm overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
{/* issue header */}
|
||||
<div className="flex items-start gap-3 px-5 py-3.5 border-b border-rule-soft bg-parchment/60">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-navy font-bold text-[0.98rem]">{a.title}</div>
|
||||
<div className="text-ink-muted text-xs mt-0.5">
|
||||
{/* filters */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
onClick={() => setFilter(f.key)}
|
||||
className={`text-[0.78rem] font-semibold rounded-full px-3.5 py-1.5 border transition-colors ${
|
||||
filter === f.key
|
||||
? "bg-navy text-parchment border-transparent"
|
||||
: "bg-surface text-ink-soft border-rule hover:bg-parchment"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
<label className="ms-auto flex items-center gap-2 text-[0.78rem] text-ink-soft font-medium cursor-pointer select-none">
|
||||
רק לא-מאומתים
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyUnverified}
|
||||
onChange={(e) => setOnlyUnverified(e.target.checked)}
|
||||
className="accent-gold w-4 h-4"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* arguments */}
|
||||
<Accordion type="multiple" defaultValue={defaultOpen} className="space-y-2.5">
|
||||
{shownArgs.map((a) => {
|
||||
const v = argVerified(a);
|
||||
const t = a.supporting.length;
|
||||
const tone =
|
||||
t === 0 || v === 0
|
||||
? "bg-rule-soft text-ink-muted"
|
||||
: v === t
|
||||
? "bg-success-bg text-success"
|
||||
: "bg-warn-bg text-warn";
|
||||
const supShown = onlyUnverified
|
||||
? a.supporting.filter((s) => !s.verified)
|
||||
: a.supporting;
|
||||
return (
|
||||
<AccordionItem
|
||||
key={a.argument_id}
|
||||
value={a.argument_id}
|
||||
className="border border-rule rounded-xl bg-surface shadow-sm overflow-hidden data-[state=open]:border-gold-soft"
|
||||
>
|
||||
<AccordionTrigger className="px-4 py-3 hover:no-underline gap-3">
|
||||
<span className="flex flex-1 items-center gap-3 min-w-0">
|
||||
<span className="flex flex-col items-start min-w-0 text-start">
|
||||
<span className="text-navy font-bold text-[0.92rem] truncate max-w-full">
|
||||
{a.title}
|
||||
</span>
|
||||
<span className="text-ink-muted text-[0.72rem]">
|
||||
סוגיה{a.legal_topic ? ` · ${a.legal_topic}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
{a.priority && (
|
||||
<span className="shrink-0 rounded-full bg-gold-wash text-gold-deep text-[0.68rem] font-semibold px-2.5 py-0.5">
|
||||
<span className="shrink-0 rounded-full bg-gold-wash text-gold-deep text-[0.66rem] font-semibold px-2.5 py-0.5">
|
||||
{PRIORITY_LABEL[a.priority] ?? a.priority}
|
||||
</span>
|
||||
)}
|
||||
<span className={`shrink-0 rounded-full text-[0.7rem] font-bold px-2.5 py-0.5 tabular-nums ${tone}`}>
|
||||
{v}/{t} ✓
|
||||
</span>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-3 pt-0">
|
||||
<div className="text-[0.72rem] font-bold text-ink-muted mb-1">
|
||||
פסיקה תומכת בקורפוס
|
||||
</div>
|
||||
|
||||
{/* supporting precedents */}
|
||||
<div className="px-5 py-3">
|
||||
<div className="text-[0.72rem] font-bold text-ink-muted mb-1">פסיקה תומכת בקורפוס</div>
|
||||
{a.supporting.length === 0 ? (
|
||||
<p className="text-ink-muted text-sm py-2">לא נמצאה פסיקה תומכת בקורפוס לסוגיה זו.</p>
|
||||
<p className="text-ink-muted text-sm py-2">
|
||||
לא נמצאה פסיקה תומכת בקורפוס לסוגיה זו.
|
||||
</p>
|
||||
) : supShown.length === 0 ? (
|
||||
<p className="text-ink-muted text-sm py-2">
|
||||
כל הפסיקה לסוגיה זו אומתה.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="list-none p-0 m-0 divide-y divide-rule-soft">
|
||||
{a.supporting.map((s) => (
|
||||
<li key={s.case_law_id} className="py-3 first:pt-1">
|
||||
<ul className="list-none p-0 m-0">
|
||||
{supShown.map((s) => {
|
||||
const noteOpen =
|
||||
openNotes.has(s.case_law_id) || !!s.chair_note;
|
||||
const quoteExpanded = expandedQuotes.has(s.case_law_id);
|
||||
return (
|
||||
<li
|
||||
key={s.case_law_id}
|
||||
className="py-3 border-t border-rule-soft first:border-t-0"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
href={`/precedents/${s.case_law_id}`}
|
||||
className="font-bold text-gold-deep text-[0.86rem] hover:text-navy"
|
||||
className="font-bold text-gold-deep text-[0.85rem] hover:text-navy"
|
||||
>
|
||||
{s.case_number || s.case_name}
|
||||
</a>
|
||||
{s.cited_by.positive > 0 && (
|
||||
<span className="rounded-full bg-success-bg text-success text-[0.68rem] font-semibold px-2 py-0.5">
|
||||
<span className="rounded-full bg-success-bg text-success text-[0.66rem] font-semibold px-2 py-0.5">
|
||||
אומץ ×{s.cited_by.positive}
|
||||
</span>
|
||||
)}
|
||||
{s.cited_by.negative > 0 && (
|
||||
<span className="rounded-full bg-danger-bg text-danger text-[0.68rem] font-semibold px-2 py-0.5">
|
||||
<span className="rounded-full bg-danger-bg text-danger text-[0.66rem] font-semibold px-2 py-0.5">
|
||||
אובחן ×{s.cited_by.negative}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{s.quote && (
|
||||
<blockquote className="border-s-[3px] border-gold bg-gold-wash text-ink-soft text-sm leading-7 rounded-e-md px-3.5 py-2 my-2 mx-0">
|
||||
{s.quote}
|
||||
</blockquote>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="ms-auto flex items-center gap-1.5">
|
||||
{s.verified ? (
|
||||
<>
|
||||
<span className="text-success text-xs font-semibold">✓ אומת ע״י היו״ר</span>
|
||||
<span className="text-success text-[0.72rem] font-semibold">
|
||||
✓ אומת ע״י היו״ר
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={verify.isPending}
|
||||
onClick={() => runVerify(a, s, false)}
|
||||
className="text-xs text-ink-muted hover:text-danger border border-rule rounded-md px-2.5 py-1 disabled:opacity-40"
|
||||
className="text-[0.72rem] text-ink-muted hover:text-danger border border-rule rounded-md px-2.5 py-1 disabled:opacity-40"
|
||||
>
|
||||
בטל אימות
|
||||
</button>
|
||||
@@ -191,7 +327,7 @@ export function CitationVerificationPanel({ caseNumber }: { caseNumber: string }
|
||||
type="button"
|
||||
disabled={verify.isPending}
|
||||
onClick={() => runVerify(a, s, true)}
|
||||
className="text-xs font-semibold text-success bg-success-bg border border-success/30 rounded-md px-3 py-1 hover:brightness-95 disabled:opacity-40"
|
||||
className="text-[0.72rem] font-semibold text-success bg-success-bg border border-success/30 rounded-md px-3 py-1 hover:brightness-95 disabled:opacity-40"
|
||||
>
|
||||
✓ מאמת
|
||||
</button>
|
||||
@@ -199,49 +335,84 @@ export function CitationVerificationPanel({ caseNumber }: { caseNumber: string }
|
||||
type="button"
|
||||
disabled={verify.isPending}
|
||||
onClick={() => runVerify(a, s, false)}
|
||||
className="text-xs font-semibold text-danger border border-rule rounded-md px-3 py-1 hover:bg-danger-bg disabled:opacity-40"
|
||||
className="text-[0.72rem] font-semibold text-danger border border-rule rounded-md px-3 py-1 hover:bg-danger-bg disabled:opacity-40"
|
||||
>
|
||||
✗ לא רלוונטי
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{s.cited_by.negative > 0 && !s.verified && (
|
||||
<span className="text-warn text-[0.72rem] ms-1">⚠ אובחן — בדוק הקשר</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{/* chair note */}
|
||||
<div className="mt-2 flex items-start gap-2">
|
||||
<label className="text-[0.72rem] font-bold text-gold-deep whitespace-nowrap pt-1.5">
|
||||
{s.quote && (
|
||||
<blockquote
|
||||
onClick={() => toggleSet(setExpandedQuotes, s.case_law_id)}
|
||||
title="לחצי להרחבה/כיווץ"
|
||||
className={`mt-2 cursor-pointer border-s-[3px] border-gold bg-gold-wash text-ink-soft text-[0.82rem] leading-6 rounded-e-md px-3 py-1.5 ${quoteExpanded ? "" : "line-clamp-2"}`}
|
||||
>
|
||||
{s.quote}
|
||||
</blockquote>
|
||||
)}
|
||||
{s.cited_by.negative > 0 && !s.verified && (
|
||||
<div className="text-warn text-[0.72rem] mt-1.5">⚠ אובחן — בדוק הקשר</div>
|
||||
)}
|
||||
{noteOpen ? (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<label className="text-[0.72rem] font-bold text-gold-deep whitespace-nowrap">
|
||||
הערת יו״ר:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={s.chair_note}
|
||||
placeholder="הוסף הערה לציטוט — מדוע תומך / הסתייגות / איך לשלב…"
|
||||
onChange={(e) => setNotes((n) => ({ ...n, [s.case_law_id]: e.target.value }))}
|
||||
onChange={(e) =>
|
||||
setNotes((n) => ({ ...n, [s.case_law_id]: e.target.value }))
|
||||
}
|
||||
onBlur={() => saveNote(a, s)}
|
||||
className="flex-1 text-[0.8rem] text-ink-soft bg-parchment border border-dashed border-rule rounded-md px-2.5 py-1.5 focus:outline-none focus:border-gold"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSet(setOpenNotes, s.case_law_id)}
|
||||
className="mt-2 text-[0.72rem] text-gold-deep font-semibold hover:underline"
|
||||
>
|
||||
+ הוסף הערת יו״ר
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* radar — unlinked digests for this issue (INV-DIG1: pointer, not citation) */}
|
||||
<RadarStrip radar={a.radar} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ k, v, tone }: { k: string; v: string; tone?: string }) {
|
||||
return (
|
||||
<span className="flex flex-col">
|
||||
<span className="text-[0.72rem] text-ink-muted">{k}</span>
|
||||
<span className={`text-lg font-bold tabular-nums mt-0.5 ${tone ?? "text-navy"}`}>{v}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Dot({ className }: { className: string }) {
|
||||
return <span className={`inline-block w-2 h-2 rounded-sm me-1 align-middle ${className}`} />;
|
||||
}
|
||||
|
||||
function RadarStrip({ radar }: { radar: RadarLead[] }) {
|
||||
if (!radar.length) return null;
|
||||
return (
|
||||
<div className="mx-5 mb-4 rounded-lg border border-dashed border-gold bg-parchment px-4 py-2.5">
|
||||
<div className="mt-3 rounded-lg border border-dashed border-gold bg-parchment px-4 py-2.5">
|
||||
<div className="text-[0.72rem] font-bold text-gold-deep mb-1">
|
||||
📡 רדאר — פסיקה שאין לנו בקורפוס (מצביע, לא מצוטט)
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user