Files
legal-ai/web-ui/src/components/precedents/halacha-review-panel.tsx
Chaim dd2e12f902
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
feat(halachot): Phase 5 — canonical panel UI + instances accordion (V41)
UI changes to halacha-review-panel.tsx:
- instance_type badge (עיקרון מקורי / ציטוט / יישום) in meta row
- "מוזכר ב-N פסיקות" pill when instance_count > 1
- CanonicalSection component: canonical_statement (view + edit), instances accordion

Backend:
- list_halachot SQL: adds canonical_id, instance_type, canonical_statement,
  instance_count via LEFT JOIN canonical_halachot
- list_canonical_instances(canonical_id) → compact rows for accordion
- GET /api/canonical-halachot/{canonical_id}/instances endpoint
- PATCH /api/halachot/{id}: canonical_statement propagates to canonical_halachot
- HalachaUpdateRequest: canonical_statement field
- useCanonicalInstances hook + CanonicalInstance type in precedent-library.ts

INV-G10 (chair gate): only the chair can edit canonical_statement (same
flow as rule_statement — PATCH /api/halachot/{id} with reviewer="דפנה").
G2: canonical data flows through canonical_halachot, not a parallel store.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 05:41:24 +00:00

1305 lines
51 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Check, X, Edit2, ChevronDown, ChevronLeft, AlertTriangle, Clock, RotateCcw, Info, Search } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { CorroborationBadge } from "./corroboration-badge";
import { practiceAreaLabel } from "./practice-area";
import {
useHalachotPending, useHalachotByStatus, useUpdateHalacha, useBatchReviewHalachot,
useLibraryStats, isExtractionFixItem, useCanonicalInstances,
type Halacha, type CanonicalInstance,
} from "@/lib/api/precedent-library";
import { AuthorityBadge, ruleTypeLabel } from "./halacha-meta";
/** #81 strict-rubric flags — why an item was held back from auto-approval. */
const QUALITY_FLAG_LABELS: Record<string, string> = {
non_decision: "אי-הכרעה",
truncated_quote: "ציטוט קטוע",
thin_restatement: "ניסוח דק",
quote_unverified: "ציטוט לא מאומת",
nli_unsupported: "כלל לא נגזר מהציטוט",
application: "יישום תלוי-עובדות",
near_duplicate: "כפילות-קרובה",
nevo_preamble_leak: "דליפת רציו נבו",
};
function formatDate(iso: string | null | undefined) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("he-IL");
} catch {
return iso;
}
}
/* Strip Unicode bidi marks that render as zero-width but shift visual position. */
function cleanCitation(s: string | null | undefined): string {
if (!s) return "—";
return s.replace(/[--]/g, "").trim();
}
type EditState = {
rule_statement: string;
reasoning_summary: string;
supporting_quote: string; // #133 — editing this re-verifies vs the source
};
// ─── Panel deliberation (#133/FU-2) ───────────────────────────────────────────
// Surfaces the 3-judge panel's vote+rationale inside the chair's review card so
// her decision — the gold label the active-learning loop learns from — is informed
// by WHY the panel split. Display-only; never affects review_status (INV-G10).
const JUDGE_META: Record<string, { name: string; lineage: string }> = {
claude: { name: "Claude", lineage: "Anthropic" },
deepseek: { name: "DeepSeek", lineage: "DeepSeek" },
gemini: { name: "Gemini", lineage: "Google" },
};
const VERDICT_META: Record<string, { label: string; cls: string }> = {
unanimous_yes: { label: "פה-אחד", cls: "bg-success text-white" },
unanimous_no: { label: "פה-אחד נגד", cls: "bg-danger text-white" },
split: { label: "פיצול", cls: "bg-warn text-white" },
incomplete: { label: "חלקי", cls: "bg-ink-muted text-white" },
};
function PanelDeliberation({ round }: { round: NonNullable<Halacha["panel_round"]> }) {
const isEntail = round.question === "entailed";
const yesLabel = isEntail ? "נתמך" : "לשמירה";
const noLabel = isEntail ? "הכלל חורג" : "לפסילה";
const question = isEntail
? "האם הציטוט תומך בכלל ואינו מרחיב מעבר לו?"
: "האם זו הלכה בת-הכללה לשמירה?";
const votes = round.judges.map((j) => j.vote).filter((v): v is boolean => v !== null);
const yes = votes.filter(Boolean).length;
const no = votes.length - yes;
const v = VERDICT_META[round.verdict] ?? VERDICT_META.incomplete;
const ratio = round.verdict === "split" ? ` ${Math.max(yes, no)}:${Math.min(yes, no)}` : "";
// The strict-vs-gist explainer only makes sense on a split over entailment.
const showHint = round.verdict === "split" && isEntail;
return (
<div className="rounded-md border border-rule overflow-hidden">
<div className="flex items-center gap-2 flex-wrap bg-navy text-parchment px-3 py-2">
<span className="text-[0.72rem] font-bold">התלבטות הפאנל</span>
<span className={`rounded-full text-[0.62rem] font-bold px-2 py-0.5 ${v.cls}`}>
{v.label}{ratio}
</span>
<span className="ms-auto text-[0.66rem] text-parchment/70" dir="rtl">
השאלה: ״{question}״
</span>
</div>
{round.judges.map((j) => {
const yesVote = j.vote === true;
const noVote = j.vote === false;
const meta = JUDGE_META[j.model] ?? { name: j.model, lineage: "" };
return (
<div key={j.model}
className="grid grid-cols-[5.5rem_6.5rem_1fr] gap-2 items-start px-3 py-2 border-t border-rule-soft">
<div className="text-[0.72rem] font-semibold text-navy leading-tight">
{meta.name}
<span className="block text-[0.6rem] text-ink-muted font-medium">{meta.lineage}</span>
</div>
<span className={`inline-flex items-center justify-self-start rounded-md text-[0.66rem] font-semibold px-2 py-0.5 whitespace-nowrap ${
yesVote ? "bg-success-bg text-success"
: noVote ? "bg-danger-bg text-danger"
: "bg-rule-soft text-ink-muted"
}`}>
{j.vote === null ? "— ללא" : yesVote ? `${yesLabel}` : `${noLabel}`}
</span>
<p className="text-[0.72rem] text-ink-soft leading-snug" dir="rtl">{j.reason || "—"}</p>
</div>
);
})}
{showHint && (
<div className="flex gap-2 items-start bg-info-bg text-[0.68rem] leading-relaxed px-3 py-2 border-t border-rule-soft" dir="rtl">
<Info className="w-3.5 h-3.5 mt-0.5 shrink-0 text-info" />
<p>
<b className="text-info">שורש המחלוקת:</b>{" "}
סטנדרט קפדני (כל פרט בכלל חייב להופיע בציטוט) מול סטנדרט-תמצית (הגרעין המשפטי נתמך).
הכרעתך קובעת איזה סטנדרט הפאנל יאמץ.
</p>
</div>
)}
{/* seedline (mockup 18): the chair's call IS the gold label the
active-learning loop trains on — surfaced on every deliberation. */}
<p
className="border-t border-rule-soft bg-gold-wash/40 px-3 py-1.5 text-[0.64rem] text-ink-muted leading-snug"
dir="rtl"
>
הכרעתך תיקלט כתווית-הזהב שממנה לומדת לולאת-הלמידה-הפעילה.
</p>
</div>
);
}
// ─── V41: Canonical section (principle statement + instances accordion) ──────
const INSTANCE_TYPE_LABELS: Record<string, string> = {
original: "עיקרון מקורי",
citation: "ציטוט",
application: "יישום",
};
const INSTANCE_TYPE_CLS: Record<string, string> = {
original: "bg-navy text-parchment",
citation: "bg-info text-white",
application: "bg-ink-muted text-white",
};
function CanonicalSection({
h, onSaveCanonical,
}: {
h: Halacha;
onSaveCanonical: (stmt: string) => Promise<void>;
}) {
const [editingCanon, setEditingCanon] = useState(false);
const [canonDraft, setCanonDraft] = useState(h.canonical_statement ?? "");
const [showInstances, setShowInstances] = useState(false);
const { data: instances, isLoading: instLoading } = useCanonicalInstances(
showInstances ? h.canonical_id : null,
);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setCanonDraft(h.canonical_statement ?? "");
}, [h.canonical_id, h.canonical_statement]);
const handleSave = async () => {
await onSaveCanonical(canonDraft);
setEditingCanon(false);
};
const instanceCount = h.instance_count ?? 1;
return (
<div className="rounded-lg border border-[#d4cdef] bg-[#f0ecfb] p-3 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="w-4 h-4 rounded-sm bg-[#6d5fa6] text-white text-[0.6rem] font-bold flex items-center justify-center flex-shrink-0">כ</span>
<span className="text-[0.7rem] font-bold text-[#6d5fa6]">ניסוח קנוני העיקרון הרחב (V41)</span>
<span className="text-[0.65rem] text-[#9089b8] ms-auto">
{instanceCount > 1 ? `מאחד ${instanceCount} פסיקות` : "instance יחיד"}
{h.review_status && ` · ${h.review_status}`}
</span>
</div>
{editingCanon ? (
<>
<Textarea
value={canonDraft}
onChange={(e) => setCanonDraft(e.target.value)}
rows={3}
dir="rtl"
className="bg-white/85 border-[#6d5fa6]/50 text-[0.82rem]"
/>
<div className="flex items-center gap-2 justify-end">
<button
type="button"
onClick={() => setEditingCanon(false)}
className="text-[0.72rem] text-ink-muted hover:text-navy"
>
ביטול
</button>
<button
type="button"
onClick={handleSave}
className="rounded-md bg-[#6d5fa6] text-white text-[0.72rem] font-semibold px-3 py-1"
>
שמור ניסוח קנוני
</button>
</div>
</>
) : (
<div className="flex items-start gap-2">
<p className="text-[0.82rem] text-ink font-medium leading-relaxed flex-1 bg-white/65 rounded px-2 py-1.5 border border-[#d4cdef]" dir="rtl">
{h.canonical_statement || <span className="text-ink-muted italic"> pending synthesis </span>}
</p>
<button
type="button"
onClick={() => setEditingCanon(true)}
className="text-[0.65rem] text-[#6d5fa6] hover:underline flex-shrink-0 mt-1"
>
ערוך
</button>
</div>
)}
{instanceCount > 1 && (
<div className="rounded-md border border-[#d4cdef] overflow-hidden">
<button
type="button"
onClick={() => setShowInstances((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 text-[0.7rem] text-[#6d5fa6] font-medium hover:bg-[#e9e4f8] transition-colors"
aria-expanded={showInstances}
>
{showInstances ? <ChevronDown className="w-3 h-3" /> : <ChevronLeft className="w-3 h-3" />}
<span>אינסטנסים {instanceCount} פסיקות שמאזכרות עיקרון זה</span>
</button>
{showInstances && (
<div className="bg-white p-2 space-y-1">
{instLoading && <p className="text-[0.7rem] text-ink-muted px-2">טוען...</p>}
{instances?.map((inst: CanonicalInstance) => (
<div key={inst.id}
className="flex items-center gap-2 rounded px-2 py-1.5 border border-rule-soft text-[0.72rem]">
<span className="font-semibold text-navy min-w-[80px]">{inst.case_number}</span>
<span className="text-ink-soft flex-1 truncate">{inst.case_name}</span>
<Badge className={`text-[0.6rem] font-bold border-0 rounded-full px-2 ${INSTANCE_TYPE_CLS[inst.instance_type] ?? "bg-rule text-ink"}`}>
{INSTANCE_TYPE_LABELS[inst.instance_type] ?? inst.instance_type}
</Badge>
{inst.confidence > 0 && (
<span className="text-ink-muted tabular-nums">{inst.confidence.toFixed(2)}</span>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
// ─── Pending-queue card (full interactions) ───────────────────────────────────
function HalachaCard({
h, focused, onApprove, onReject, onDefer, onSave,
}: {
h: Halacha & { variants?: Halacha[] };
focused: boolean;
onApprove: () => void;
onReject: () => void;
onDefer: () => void;
onSave: (patch: Partial<EditState>) => Promise<void>;
}) {
const variants = h.variants ?? [];
const equivalents = h.equivalents ?? [];
const [showVariants, setShowVariants] = useState(false);
const [showEquiv, setShowEquiv] = useState(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<EditState>({
rule_statement: h.rule_statement,
reasoning_summary: h.reasoning_summary,
supporting_quote: h.supporting_quote,
});
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setDraft({
rule_statement: h.rule_statement,
reasoning_summary: h.reasoning_summary,
supporting_quote: h.supporting_quote,
});
}, [h.id, h.rule_statement, h.reasoning_summary, h.supporting_quote]);
const onSubmitEdit = async () => {
// Only send the quote when it actually changed — that triggers server-side
// re-verification + flag sync (#133); unchanged edits keep the old behavior.
const patch: Partial<EditState> = {
rule_statement: draft.rule_statement,
reasoning_summary: draft.reasoning_summary,
};
if (draft.supporting_quote !== h.supporting_quote) {
patch.supporting_quote = draft.supporting_quote;
}
await onSave(patch);
setEditing(false);
};
return (
<div
data-halacha-id={h.id}
className={`
rounded-lg border p-4 space-y-3 transition-colors
${h.panel_round ? "bg-gold-wash" : "bg-surface"}
${focused ? "border-gold ring-2 ring-gold/40 shadow-md" : "border-rule"}
`}
>
<div className="flex items-start gap-2 text-[0.78rem] text-ink-muted flex-wrap">
{/* gold "הלכה" tag opening the meta row (mockup 19 `.b-hal`) */}
<Badge className="rounded bg-gold text-white border-0 text-[0.62rem] font-bold tracking-wide">
הלכה
</Badge>
{/* V41: instance_type badge */}
{h.instance_type && (
<Badge className={`rounded border-0 text-[0.62rem] font-bold ${INSTANCE_TYPE_CLS[h.instance_type] ?? "bg-rule text-ink"}`}>
{INSTANCE_TYPE_LABELS[h.instance_type] ?? h.instance_type}
</Badge>
)}
{/* V41: instance count pill */}
{(h.instance_count ?? 0) > 1 && (
<Badge variant="outline" className="text-[0.62rem] border-[#d4cdef] text-[#6d5fa6] bg-[#f0ecfb]">
מוזכר ב-{h.instance_count} פסיקות
</Badge>
)}
{h.page_reference && (
<span className="text-[0.7rem]">{h.page_reference}</span>
)}
<span className="ms-auto flex items-center gap-2">
{h.quote_verified ? (
<Badge variant="outline" className="text-[0.65rem] bg-gold-wash text-gold-deep border-gold/40">
<Check className="w-3 h-3 me-1" /> ציטוט מאומת
</Badge>
) : (
<Badge variant="outline" className="text-[0.65rem] bg-danger-bg text-danger border-danger/40">
<AlertTriangle className="w-3 h-3 me-1" /> ציטוט לא מאומת
</Badge>
)}
<Badge variant="outline" className="text-[0.65rem] tabular-nums">
ביטחון {h.confidence.toFixed(2)}
</Badge>
<Badge variant="outline" className="text-[0.65rem]">
{ruleTypeLabel(h.rule_type)}
</Badge>
<AuthorityBadge authority={h.authority} />
{variants.length > 0 && (
<Badge variant="outline"
className="text-[0.65rem] bg-navy-soft/30 text-navy border-navy/30">
+{variants.length} וריאנטים
</Badge>
)}
{equivalents.length > 0 && (
<Badge variant="outline"
className="text-[0.65rem] bg-gold-wash text-gold-deep border-gold/40">
עיקרון מקביל ב-{equivalents.length}
</Badge>
)}
<CorroborationBadge halacha={h} />
</span>
</div>
{h.quality_flags && h.quality_flags.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{h.quality_flags.map((f) => (
<Badge key={f} variant="outline"
className="text-[0.65rem] bg-danger-bg text-danger border-danger/40">
<AlertTriangle className="w-3 h-3 me-1" />
{QUALITY_FLAG_LABELS[f] ?? f}
</Badge>
))}
</div>
)}
<div className="grid md:grid-cols-2 gap-3">
<div>
<div className="text-[0.7rem] text-ink-muted mb-1">ניסוח הכלל</div>
{editing ? (
<Textarea
value={draft.rule_statement} rows={4} dir="rtl"
onChange={(e) => setDraft({ ...draft, rule_statement: e.target.value })}
className="bg-gold-wash/50 border-gold/30"
/>
) : (
<p className="text-navy font-medium leading-relaxed" dir="rtl">
{h.rule_statement}
</p>
)}
</div>
<div>
<div className="text-[0.7rem] text-ink-muted mb-1">ציטוט תומך</div>
{editing ? (
<>
<Textarea
value={draft.supporting_quote} rows={4} dir="rtl"
onChange={(e) => setDraft({ ...draft, supporting_quote: e.target.value })}
className="bg-gold-wash/50 border-gold/30"
/>
<p className="text-[0.66rem] text-info mt-1 leading-snug">
השמירה מאמתת את הציטוט מול טקסט-המקור; אם תואם הדגל ״ציטוט לא-מאומת״ יוסר.
</p>
</>
) : (
<blockquote className="text-ink-soft text-sm leading-relaxed border-r-2 border-gold pr-3" dir="rtl">
&ldquo;{h.supporting_quote}&rdquo;
</blockquote>
)}
</div>
</div>
{h.panel_round && <PanelDeliberation round={h.panel_round} />}
{(editing || h.reasoning_summary) && (
<div>
<div className="text-[0.7rem] text-ink-muted mb-1">תמצית ההיגיון</div>
{editing ? (
<Textarea
value={draft.reasoning_summary} rows={2} dir="rtl"
onChange={(e) => setDraft({ ...draft, reasoning_summary: e.target.value })}
className="bg-gold-wash/50 border-gold/30"
/>
) : (
<p className="text-ink-soft text-sm leading-relaxed" dir="rtl">
{h.reasoning_summary}
</p>
)}
</div>
)}
<div className="flex items-center gap-2 flex-wrap">
{h.practice_areas?.map((p) => (
<Badge key={p} variant="outline" className="text-[0.65rem] bg-navy-soft/30 text-navy">
{practiceAreaLabel(p)}
</Badge>
))}
{h.subject_tags?.map((t) => (
<Badge key={t} variant="outline" className="text-[0.65rem]">
{t}
</Badge>
))}
</div>
{variants.length > 0 && (
<div className="rounded-md border border-navy/20 bg-navy-soft/10">
<button
type="button"
onClick={() => setShowVariants((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 text-[0.72rem] text-navy hover:bg-navy-soft/20 transition-colors"
aria-expanded={showVariants}
>
{showVariants ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />}
<span className="font-medium">
{variants.length} ניסוחים כמעט-זהים של אותו עיקרון
</span>
<span className="me-auto text-ink-muted">החלטה תחול על כולם</span>
</button>
{showVariants && (
<ul className="px-4 pb-3 pt-1 space-y-2">
{variants.map((v) => (
<li key={v.id} className="text-[0.78rem] text-ink-soft leading-relaxed border-r-2 border-navy/20 pr-3" dir="rtl">
{v.rule_statement}
<span className="text-[0.65rem] text-ink-muted tabular-nums ms-2">
(ביטחון {v.confidence.toFixed(2)})
</span>
</li>
))}
</ul>
)}
</div>
)}
{equivalents.length > 0 && (
<div className="rounded-md border border-gold/30 bg-gold-wash/40">
<button
type="button"
onClick={() => setShowEquiv((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 text-[0.72rem] text-gold-deep hover:bg-gold-wash/70 transition-colors"
aria-expanded={showEquiv}
>
{showEquiv ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />}
<span className="font-medium">
עיקרון מקביל ב-{equivalents.length} החלטות אחרות (אסמכתה מקבילה)
</span>
<span className="me-auto text-ink-muted">לא ציטוט הישנות עצמאית</span>
</button>
{showEquiv && (
<ul className="px-4 pb-3 pt-1 space-y-2">
{equivalents.map((e) => (
<li key={e.halacha_id} className="text-[0.78rem] text-ink-soft leading-relaxed border-r-2 border-gold/30 pr-3" dir="rtl">
<span className="font-semibold text-navy">{cleanCitation(e.case_number)}</span>
{" — "}{e.rule_statement}
{e.cosine != null && (
<span className="text-[0.65rem] text-ink-muted tabular-nums ms-2">
(דמיון {e.cosine.toFixed(2)})
</span>
)}
</li>
))}
</ul>
)}
</div>
)}
{/* V41: canonical statement + instances accordion */}
{h.canonical_id && (
<CanonicalSection
h={h}
onSaveCanonical={async (stmt) => {
await onSave({ canonical_statement: stmt } as Parameters<typeof onSave>[0]);
}}
/>
)}
<div className="flex items-center gap-2 justify-end pt-1 border-t border-rule-soft">
{editing ? (
<>
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
ביטול
</Button>
<Button size="sm" onClick={onSubmitEdit}
className="bg-navy text-parchment hover:bg-navy-soft">
שמור שינויים
</Button>
</>
) : (
<>
<Button size="sm" variant="ghost" onClick={() => setEditing(true)}>
<Edit2 className="w-3.5 h-3.5 me-1" />
ערוך (E)
</Button>
<Button size="sm" variant="ghost" onClick={onDefer}
className="text-ink-muted hover:text-navy">
<Clock className="w-3.5 h-3.5 me-1" />
דחה למועד (D)
</Button>
<Button size="sm" variant="ghost"
onClick={onReject}
className="text-danger hover:text-danger hover:bg-danger-bg">
<X className="w-3.5 h-3.5 me-1" />
דחה (R)
</Button>
<Button size="sm" onClick={onApprove}
className="bg-gold text-navy hover:bg-gold-deep">
<Check className="w-3.5 h-3.5 me-1" />
אשר (A)
</Button>
</>
)}
</div>
</div>
);
}
// ─── Restore card (rejected / approved tabs) ──────────────────────────────────
function HalachaRestoreCard({
h,
primaryLabel,
primaryAction,
secondaryLabel,
secondaryAction,
}: {
h: Halacha;
primaryLabel: string;
primaryAction: () => void;
secondaryLabel: string;
secondaryAction: () => void;
}) {
return (
<div className="rounded-lg border border-rule bg-surface p-4 space-y-3">
<div className="flex items-start gap-2 text-[0.78rem] text-ink-muted flex-wrap">
{h.page_reference && <span className="text-[0.7rem]">{h.page_reference}</span>}
<span className="ms-auto flex items-center gap-2">
<Badge variant="outline" className="text-[0.65rem] tabular-nums">
ביטחון {h.confidence.toFixed(2)}
</Badge>
<Badge variant="outline" className="text-[0.65rem]">
{ruleTypeLabel(h.rule_type)}
</Badge>
<AuthorityBadge authority={h.authority} />
<CorroborationBadge halacha={h} />
</span>
</div>
<div className="grid md:grid-cols-2 gap-3">
<div>
<div className="text-[0.7rem] text-ink-muted mb-1">ניסוח הכלל</div>
<p className="text-navy font-medium leading-relaxed" dir="rtl">
{h.rule_statement}
</p>
</div>
<div>
<div className="text-[0.7rem] text-ink-muted mb-1">ציטוט תומך</div>
<blockquote className="text-ink-soft text-sm leading-relaxed border-r-2 border-gold pr-3" dir="rtl">
&ldquo;{h.supporting_quote}&rdquo;
</blockquote>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
{h.practice_areas?.map((p) => (
<Badge key={p} variant="outline" className="text-[0.65rem] bg-navy-soft/30 text-navy">
{practiceAreaLabel(p)}
</Badge>
))}
</div>
<div className="flex items-center gap-2 justify-end pt-1 border-t border-rule-soft">
<Button size="sm" variant="ghost"
onClick={secondaryAction}
className="text-ink-muted hover:text-navy">
<RotateCcw className="w-3.5 h-3.5 me-1" />
{secondaryLabel}
</Button>
<Button size="sm" onClick={primaryAction}
className="bg-gold text-navy hover:bg-gold-deep">
<Check className="w-3.5 h-3.5 me-1" />
{primaryLabel}
</Button>
</div>
</div>
);
}
// ─── Shared group type ────────────────────────────────────────────────────────
/** #84.2 — a review card: the canonical halacha plus its near-duplicate
* variants (empty for singletons). Acting on the card acts on the whole set. */
type ReviewItem = Halacha & { variants: Halacha[] };
type Group = {
caseLawId: string;
caseNumber: string;
court: string;
decisionDate: string | null;
precedentLevel: string;
items: ReviewItem[]; // canonicals (clusters collapsed)
pendingTotal: number; // total halachot incl. variants (for the count badge)
};
/** All halacha ids represented by a review item (canonical + its variants). */
function itemIds(it: ReviewItem): string[] {
return [it.id, ...it.variants.map((v) => v.id)];
}
function buildGroups(items: Halacha[]): Group[] {
const byCase = new Map<string, Halacha[]>();
for (const h of items) {
const arr = byCase.get(h.case_law_id) ?? [];
arr.push(h);
byCase.set(h.case_law_id, arr);
}
const groups: Group[] = [];
for (const [caseLawId, members] of byCase) {
// collapse near-duplicate clusters (#84.2): one card per cluster_id.
const clusters = new Map<string, Halacha[]>();
for (const h of members) {
const key = h.cluster_id ?? h.id;
const arr = clusters.get(key) ?? [];
arr.push(h);
clusters.set(key, arr);
}
const reviewItems: ReviewItem[] = [];
for (const mem of clusters.values()) {
// canonical = strongest phrasing (highest confidence, verified wins ties)
mem.sort((a, b) =>
b.confidence - a.confidence
|| Number(b.quote_verified) - Number(a.quote_verified));
reviewItems.push({ ...mem[0], variants: mem.slice(1) });
}
// active-learning order within the case: most-uncertain first
reviewItems.sort((a, b) => a.confidence - b.confidence);
const first = members[0];
groups.push({
caseLawId,
caseNumber: first.case_number ?? "",
court: first.court ?? "",
decisionDate: first.decision_date ?? null,
precedentLevel: first.precedent_level ?? "",
items: reviewItems,
pendingTotal: members.length,
});
}
return groups.sort((a, b) => b.pendingTotal - a.pendingTotal);
}
// ─── Restore panel (used for "rejected" and "approved" tabs) ──────────────────
function RestorePanel({
status,
primaryLabel,
getPrimaryStatus,
secondaryLabel,
getSecondaryStatus,
}: {
status: string;
primaryLabel: string;
getPrimaryStatus: () => "approved" | "rejected" | "pending_review";
secondaryLabel: string;
getSecondaryStatus: () => "approved" | "rejected" | "pending_review";
}) {
const { data, isPending, error } = useHalachotByStatus(status);
const update = useUpdateHalacha();
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const groups = useMemo<Group[]>(
() => buildGroups(data?.items ?? []),
[data],
);
const restore = async (h: Halacha, newStatus: "approved" | "rejected" | "pending_review") => {
try {
await update.mutateAsync({ id: h.id, patch: { review_status: newStatus } });
toast.success("עודכן");
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
};
const toggleGroup = (caseLawId: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(caseLawId)) next.delete(caseLawId);
else next.add(caseLawId);
return next;
});
};
if (error) {
return (
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
{error.message}
</div>
);
}
if (isPending) {
return (
<div className="space-y-3">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div>
);
}
if (!groups.length) {
return (
<div className="text-center text-ink-muted py-16">
<p className="text-lg">אין הלכות בסטטוס זה.</p>
</div>
);
}
return (
<div className="space-y-3">
{groups.map((g) => {
const isOpen = expandedIds.has(g.caseLawId);
return (
<div key={g.caseLawId} className="rounded-lg border border-rule bg-surface overflow-hidden">
<button
type="button"
onClick={() => toggleGroup(g.caseLawId)}
className={`
w-full flex items-center gap-3 px-4 py-3 text-right
hover:bg-gold-wash/30 transition-colors
${isOpen ? "bg-gold-wash/40 border-b border-rule" : ""}
`}
aria-expanded={isOpen}
>
{isOpen ? (
<ChevronDown className="w-4 h-4 text-ink-muted shrink-0" />
) : (
<ChevronLeft className="w-4 h-4 text-ink-muted shrink-0" />
)}
<div className="flex-1 min-w-0 text-right">
<div className="font-semibold text-navy truncate">
{cleanCitation(g.caseNumber)}
</div>
<div className="text-[0.72rem] text-ink-muted flex items-center gap-2 flex-wrap mt-0.5">
{g.court && <span>{g.court}</span>}
{g.decisionDate && <span>· {formatDate(g.decisionDate)}</span>}
</div>
</div>
<Badge variant="outline" className="tabular-nums">
{g.pendingTotal}
</Badge>
</button>
{isOpen && (
<div className="p-4 space-y-3 bg-rule-soft/20">
{g.items.map((h) => (
<HalachaRestoreCard
key={h.id}
h={h}
primaryLabel={primaryLabel}
primaryAction={() => restore(h, getPrimaryStatus())}
secondaryLabel={secondaryLabel}
secondaryAction={() => restore(h, getSecondaryStatus())}
/>
))}
</div>
)}
</div>
);
})}
</div>
);
}
// ─── Pending queue panel (main review flow) ───────────────────────────────────
function PendingPanel() {
// #133 unified queue — ONE fetch of all pending, split client-side by ACTION:
// "judgment" = items the panel deliberated (or clean) → chair approves/rejects;
// "fix" = flagged-but-never-adjudicated → extraction repair. (No empty "תור נקי".)
const [view, setView] = useState<"judgment" | "fix">("judgment");
// Locate a pending halacha by decision/text — server-side, so an item ranked
// below the 500-item display window is still reachable (the chair's core ask).
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
useEffect(() => {
const t = setTimeout(() => setSearch(searchInput.trim()), 300);
return () => clearTimeout(t);
}, [searchInput]);
const searching = search.length > 0;
const { data, isPending, error } = useHalachotPending({ limit: 500, search });
const update = useUpdateHalacha();
const batch = useBatchReviewHalachot();
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [focusedId, setFocusedId] = useState<string | null>(null);
const allItems = useMemo(() => data?.items ?? [], [data]);
// Full pending count for this filter (incl. items beyond the display window).
const pendingTotal = data?.total ?? allItems.length;
const judgmentCount = useMemo(
() => allItems.filter((h) => !isExtractionFixItem(h)).length, [allItems]);
const fixCount = useMemo(
() => allItems.filter(isExtractionFixItem).length, [allItems]);
const segmentItems = useMemo(
() => allItems.filter((h) =>
view === "fix" ? isExtractionFixItem(h) : !isExtractionFixItem(h)),
[allItems, view],
);
const groups = useMemo<Group[]>(() => buildGroups(segmentItems), [segmentItems]);
const totalCount = segmentItems.length;
const visibleItems = useMemo<ReviewItem[]>(() => {
const out: ReviewItem[] = [];
for (const g of groups) {
// a search narrows to a handful of cases — show them all open
if (searching || expandedIds.has(g.caseLawId)) out.push(...g.items);
}
return out;
}, [groups, expandedIds, searching]);
useEffect(() => {
if (focusedId === null) return;
if (visibleItems.some((h) => h.id === focusedId)) return;
setFocusedId(visibleItems[0]?.id ?? null);
}, [focusedId, visibleItems]);
useEffect(() => {
if (!focusedId) return;
const el = document.querySelector(`[data-halacha-id="${focusedId}"]`);
el?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [focusedId]);
const focused: ReviewItem | null = focusedId
? visibleItems.find((h) => h.id === focusedId) ?? null
: null;
const moveFocus = (delta: 1 | -1) => {
if (visibleItems.length === 0) return;
const idx = focusedId
? visibleItems.findIndex((h) => h.id === focusedId)
: -1;
const next = idx < 0
? (delta > 0 ? 0 : visibleItems.length - 1)
: Math.max(0, Math.min(visibleItems.length - 1, idx + delta));
setFocusedId(visibleItems[next].id);
};
const REVIEW_TOAST: Record<string, string> = {
approved: "אושר", rejected: "נדחה", deferred: "נדחה למועד",
};
const review = async (
it: ReviewItem,
status: "approved" | "rejected" | "deferred",
extra?: Partial<EditState>,
) => {
const ids = itemIds(it);
try {
// #84.2 — a cluster card applies the decision to all its variants at once
// (an edit, which is canonical-specific, stays single).
if (ids.length > 1 && !extra) {
await batch.mutateAsync({ ids, status });
} else {
await update.mutateAsync({
id: it.id,
patch: { review_status: status, ...extra },
});
}
toast.success(
(REVIEW_TOAST[status] ?? "עודכן")
+ (ids.length > 1 ? ` (${ids.length} וריאנטים)` : ""),
);
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
};
const reviewGroup = async (g: Group, status: "approved" | "rejected") => {
try {
const res = await batch.mutateAsync({
ids: g.items.flatMap(itemIds), status,
});
toast.success(
`${status === "approved" ? "אושרו" : "נדחו"} ${res.updated} הלכות`,
);
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
};
const toggleGroup = (caseLawId: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(caseLawId)) {
next.delete(caseLawId);
} else {
next.add(caseLawId);
const g = groups.find((x) => x.caseLawId === caseLawId);
if (g && g.items.length) {
setTimeout(() => setFocusedId(g.items[0].id), 0);
}
}
return next;
});
};
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName?.toLowerCase();
if (tag === "input" || tag === "textarea") return;
if (e.key === "j") {
e.preventDefault();
moveFocus(1);
} else if (e.key === "k") {
e.preventDefault();
moveFocus(-1);
} else if ((e.key === "a" || e.key === "A") && focused) {
e.preventDefault();
review(focused, "approved");
} else if ((e.key === "r" || e.key === "R") && focused) {
e.preventDefault();
if (window.confirm("לדחות הלכה זו?")) review(focused, "rejected");
} else if ((e.key === "d" || e.key === "D") && focused) {
e.preventDefault();
review(focused, "deferred");
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focused, visibleItems]);
const segBtn = (key: "judgment" | "fix", label: string, count: number) => (
<Button
size="sm"
variant={view === key ? "default" : "ghost"}
className={view === key ? "bg-gold text-navy hover:bg-gold-deep" : ""}
onClick={() => setView(key)}
>
{label}
<span className={`ms-2 text-[0.7rem] px-1.5 py-0.5 rounded-full tabular-nums
${view === key ? "bg-navy/15" : "bg-black/10 text-ink-muted"}`}>
{count}
</span>
</Button>
);
// #133 — two segments by ACTION (deliberated→judge vs flagged→fix); no empty "תור נקי".
const viewToggle = (
<div className="flex items-center gap-1 rounded-lg border border-rule p-0.5 bg-rule-soft/30 w-fit">
{segBtn("judgment", "להכרעתך", judgmentCount)}
{segBtn("fix", "דורש תיקון-חילוץ", fixCount)}
</div>
);
let body: ReactNode;
if (error) {
body = (
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
{error.message}
</div>
);
} else if (isPending) {
body = (
<div className="space-y-3">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div>
);
} else if (!groups.length) {
body = searching ? (
<div className="text-center text-ink-muted py-16">
<p className="text-lg">לא נמצאו הלכות ממתינות התואמות ל״{search}״.</p>
<p className="text-sm mt-2">
נסה מספר-פס״ד, שם-תיק או מילה מנוסח-ההלכה או נקה את החיפוש.
{view === "judgment" && fixCount > 0 && " ייתכן שההתאמות נמצאות ב״דורש תיקון-חילוץ״."}
{view === "fix" && judgmentCount > 0 && " ייתכן שההתאמות נמצאות ב״להכרעתך״."}
</p>
</div>
) : (
<div className="text-center text-ink-muted py-16">
<p className="text-lg">
{view === "fix"
? "אין הלכות הממתינות לתיקון-חילוץ."
: "אין הלכות הממתינות להכרעתך."}
</p>
<p className="text-sm mt-2">
{view === "fix"
? "פריטים שסומנו בפגם-חילוץ (ציטוט לא-מאומת / קטוע / כפילות) ושלא עברו התלבטות-פאנל יופיעו כאן."
: "הלכות שהפאנל הכריע או דן בהן יופיעו כאן לאישורך."}
</p>
</div>
);
} else {
body = (
<div className="space-y-4">
<div className="flex items-center gap-3 text-sm text-ink-muted flex-wrap">
<span>
<span className="text-navy font-semibold">{totalCount}</span>
{view === "fix" ? " לתיקון-חילוץ" : " להכרעה"}
{" "}ב-<span className="text-navy font-semibold">{groups.length}</span> פסיקות
{view === "judgment" && (
<span className="text-[0.72rem] ms-2">· ממוין: פיצול-פאנל תחילה</span>
)}
</span>
<span className="me-auto text-[0.72rem]">
ניווט: <kbd className="bg-rule-soft px-1.5 rounded">J</kbd>/<kbd className="bg-rule-soft px-1.5 rounded">K</kbd>
{" "}· אישור: <kbd className="bg-rule-soft px-1.5 rounded">A</kbd>
{" "}· דחייה: <kbd className="bg-rule-soft px-1.5 rounded">R</kbd>
{" "}· למועד: <kbd className="bg-rule-soft px-1.5 rounded">D</kbd>
{" "}· עריכה: <kbd className="bg-rule-soft px-1.5 rounded">E</kbd>
</span>
{expandedIds.size > 0 && (
<Button size="sm" variant="ghost" onClick={() => setExpandedIds(new Set())}>
סגור הכל
</Button>
)}
</div>
<div className="space-y-3">
{groups.map((g) => {
const isOpen = searching || expandedIds.has(g.caseLawId);
return (
<div key={g.caseLawId} className="rounded-lg border border-rule bg-surface overflow-hidden">
<button
type="button"
onClick={() => toggleGroup(g.caseLawId)}
className={`
w-full flex items-center gap-3 px-4 py-3 text-right
hover:bg-gold-wash/30 transition-colors
${isOpen ? "bg-gold-wash/40 border-b border-rule" : ""}
`}
aria-expanded={isOpen}
>
{isOpen ? (
<ChevronDown className="w-4 h-4 text-ink-muted shrink-0" />
) : (
<ChevronLeft className="w-4 h-4 text-ink-muted shrink-0" />
)}
<div className="flex-1 min-w-0 text-right">
<div className="font-semibold text-navy truncate">
{cleanCitation(g.caseNumber)}
</div>
<div className="text-[0.72rem] text-ink-muted flex items-center gap-2 flex-wrap mt-0.5">
{g.court && <span>{g.court}</span>}
{g.decisionDate && <span>· {formatDate(g.decisionDate)}</span>}
{g.precedentLevel && <span>· {g.precedentLevel}</span>}
</div>
</div>
<Badge variant="outline" className="bg-gold-wash text-gold-deep border-gold/40 tabular-nums">
{g.pendingTotal} ממתינות
{g.pendingTotal !== g.items.length && ` · ${g.items.length} כרטיסים`}
</Badge>
</button>
{isOpen && (
<div className="p-4 space-y-3 bg-rule-soft/20">
<div className="flex items-center gap-2 justify-end pb-1">
<span className="me-auto text-[0.72rem] text-ink-muted">
פעולה קבוצתית על {g.pendingTotal} ההלכות:
</span>
<Button
size="sm" variant="ghost" disabled={batch.isPending}
onClick={() => {
if (window.confirm(`לדחות את כל ${g.pendingTotal} ההלכות בפסק זה?`))
reviewGroup(g, "rejected");
}}
className="text-danger hover:text-danger hover:bg-danger-bg"
>
<X className="w-3.5 h-3.5 me-1" /> דחה הכל
</Button>
<Button
size="sm" disabled={batch.isPending}
onClick={() => {
if (window.confirm(`לאשר את כל ${g.pendingTotal} ההלכות בפסק זה?`))
reviewGroup(g, "approved");
}}
className="bg-gold text-navy hover:bg-gold-deep"
>
<Check className="w-3.5 h-3.5 me-1" /> אשר הכל
</Button>
</div>
{g.items.map((h) => (
<HalachaCard
key={h.id}
h={h}
focused={h.id === focusedId}
onApprove={() => review(h, "approved")}
onReject={() => {
if (window.confirm("לדחות הלכה זו?")) review(h, "rejected");
}}
onDefer={() => review(h, "deferred")}
onSave={async (patch) => {
try {
await update.mutateAsync({ id: h.id, patch });
toast.success("נשמר");
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
}}
/>
))}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
const overWindow = !searching && pendingTotal > allItems.length;
return (
<div className="space-y-4">
{/* Locate bar — reach any pending halacha, not only the top window */}
<div className="flex items-center gap-2.5 rounded-lg border border-rule bg-surface px-3.5 py-2.5 shadow-sm">
<Search className="w-4 h-4 text-ink-muted shrink-0" />
<input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="חיפוש בתור — מספר פס״ד, שם-תיק או נוסח-הלכה"
dir="rtl"
className="flex-1 bg-transparent border-0 outline-none text-sm text-ink placeholder:text-ink-muted"
/>
{searchInput && (
<button
type="button"
onClick={() => setSearchInput("")}
className="shrink-0 w-5 h-5 rounded-full bg-rule-soft text-ink-muted hover:text-navy
flex items-center justify-center text-[0.7rem]"
title="נקה"
>
<X className="w-3 h-3" />
</button>
)}
</div>
{overWindow && (
<p className="flex items-center gap-2 text-[0.78rem] text-ink-muted px-1 leading-relaxed">
<span className="bg-info-bg text-info rounded-full px-2 py-0.5 font-semibold text-[0.72rem] shrink-0">
חלון התצוגה
</span>
<span>
התור מציג את <b className="text-navy">{allItems.length}</b> ההלכות בעלות-העדיפות מתוך{" "}
<b className="text-navy">{pendingTotal}</b> הממתינות. הלכה מחוץ לחלון אתר אותה בחיפוש.
</span>
</p>
)}
{searching && (
<div className="flex items-center gap-2 flex-wrap rounded-lg border border-gold/50 bg-gold-wash px-3.5 py-2.5 text-sm text-navy">
<span>מציג ממתינות עבור</span>
<span className="rounded bg-navy text-parchment text-[0.78rem] font-semibold px-2 py-0.5">
{search}
</span>
<span className="text-ink-muted">
· {pendingTotal} {pendingTotal === 1 ? "התאמה" : "התאמות"}
</span>
<button
type="button"
onClick={() => setSearchInput("")}
className="ms-auto text-gold-deep font-semibold text-[0.8rem] underline hover:no-underline"
>
נקה חיפוש · חזרה לכל התור
</button>
</div>
)}
{viewToggle}
{body}
</div>
);
}
// ─── Main export ──────────────────────────────────────────────────────────────
type Tab = "pending" | "rejected" | "approved";
const TAB_LABELS: Record<Tab, string> = {
pending: "ממתינות",
rejected: "נדחו",
approved: "אושרו",
};
export function HalachaReviewPanel() {
const [tab, setTab] = useState<Tab>("pending");
// Real COUNT(*) totals from the stats endpoint — not the limit-capped
// /api/halachot len(rows), which pinned these chips at 500/1000/1000 and only
// refreshed on navigation. The stats query polls, so the chips track live (#6/#7/#8).
const { data: stats } = useLibraryStats();
const counts: Record<Tab, number | null> = {
pending: stats?.halachot_pending ?? null,
rejected: stats?.halachot_rejected ?? null,
approved: stats?.halachot_approved ?? null,
};
return (
<div className="space-y-4">
{/* Tab bar */}
<div className="flex gap-1 border-b border-rule pb-0">
{(["pending", "rejected", "approved"] as Tab[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={`
px-4 py-2 text-sm font-medium rounded-t-md transition-colors
${tab === t
? "bg-surface border border-b-surface border-rule text-navy -mb-px"
: "text-ink-muted hover:text-navy hover:bg-rule-soft/40"
}
`}
>
{TAB_LABELS[t]}
{counts[t] !== null && counts[t]! > 0 && (
<span className={`
ms-2 text-[0.7rem] px-1.5 py-0.5 rounded-full tabular-nums
${t === "pending"
? "bg-gold-wash text-gold-deep"
: t === "rejected"
? "bg-danger-bg text-danger"
: "bg-navy-soft/20 text-navy"
}
`}>
{counts[t]}
</span>
)}
</button>
))}
</div>
{/* Tab content */}
{tab === "pending" && <PendingPanel />}
{tab === "rejected" && (
<RestorePanel
status="rejected"
primaryLabel="אשר"
getPrimaryStatus={() => "approved"}
secondaryLabel="שחזר לתור"
getSecondaryStatus={() => "pending_review"}
/>
)}
{tab === "approved" && (
<RestorePanel
status="approved"
primaryLabel="בטל אישור"
getPrimaryStatus={() => "pending_review"}
secondaryLabel="דחה"
getSecondaryStatus={() => "rejected"}
/>
)}
</div>
);
}