All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 6s
אושר ב-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>
1073 lines
41 KiB
TypeScript
1073 lines
41 KiB
TypeScript
"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">
|
||
“{h.supporting_quote}”
|
||
</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">
|
||
“{h.supporting_quote}”
|
||
</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>
|
||
);
|
||
}
|