Files
legal-ai/web-ui/src/components/precedents/halacha-review-panel.tsx
Chaim 2962538c09
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 6s
feat: תיקון-ציטוט בדלי-החילוץ + קישור-לתור מדף-פרט (#133 follow-ups)
אושר ב-Claude Design (כרטיס 20-halacha-followups).

א׳ תיקון-חילוץ אמיתי ל-quote_unverified:
- `update_halacha` מקבל `supporting_quote`; בעדכונו מריץ `_verify_quote`
  הקיים מול `case_law.full_text` השמור (דטרמיניסטי — בלי OCR/LLM מחדש,
  feedback_no_reocr_retrofit) ומסנכרן `quote_verified` + מוסיף/מסיר את
  הדגל `quote_unverified`. יו"ר שמדביק את הנוסח הנכון מהמקור → הדגל נמחק
  → ההלכה עוזבת את דלי-החילוץ. `HalachaUpdateRequest`+handler מעבירים את
  השדה; `HalachaPatch` + מצב-העריכה ב-HalachaCard כוללים textarea-ציטוט
  (נשלח רק כששונה) + hint.

ב׳ דף-פרט פסיקה — ביטול כפילות-המשטח:
- הלכה pending ב-`ExtractedHalachotSection` מציגה קישור "עבור לתור הלכות"
  במקום כפתורי אשר/דחה כפולים (שער-אישור יחיד, INV-IA/G10).
- `/precedents` Tabs הפך נשלט וקורא `?tab=review` (post-mount, בלי
  hydration-mismatch) כדי שהקישור ינחת על טאב-התור.

display-only ל-G10 (האימות מסנכרן מטא-איכות, לא review_status). ולידציה:
py_compile + tsc + eslint נקיים.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 09:03:29 +00:00

1073 lines
41 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 } 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,
isExtractionFixItem, type Halacha,
} 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>
)}
</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 bg-surface p-4 space-y-3 transition-colors
${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">
{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>
)}
<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");
const { data, isPending, error } = useHalachotPending({ limit: 500 });
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]);
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) {
if (expandedIds.has(g.caseLawId)) out.push(...g.items);
}
return out;
}, [groups, expandedIds]);
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 = (
<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 = 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>
);
}
return (
<div className="space-y-4">
{viewToggle}
{body}
</div>
);
}
// ─── Count badge for tabs ─────────────────────────────────────────────────────
function useHalachaCount(status: string) {
const { data } = useHalachotByStatus(status, 1000);
return data?.count ?? data?.items.length ?? null;
}
// ─── 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");
const { data: pendingData } = useHalachotPending({ limit: 500 });
const rejectedCount = useHalachaCount("rejected");
const approvedCount = useHalachaCount("approved");
const pendingCount = pendingData?.items.length ?? null;
const counts: Record<Tab, number | null> = {
pending: pendingCount,
rejected: rejectedCount,
approved: approvedCount,
};
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>
);
}