Files
legal-ai/web-ui/src/components/precedents/halacha-review-panel.tsx
Chaim 2e33cac043 fix(halacha): split authority (derived) from rule_role — stop source-conflation (INV-DM7)
The extractor classified rule_type by SOURCE bindingness (higher-court→binding,
committee→persuasive) instead of by rule KIND. The gold-set proved it: 'binding'
appeared on 19/19 external rulings & 0 committees; 'persuasive' on 13/13
committees & 0 external — only 58% agreement with the human role tags. The two
axes (authority vs rule role) were crammed into one enum.

This splits them per INV-DM7:
- authority (binding/persuasive) — DERIVED from case_law.precedent_level
  (עליון/מנהלי→binding, ועדת_ערר_מחוזית→persuasive), never stored, never
  LLM-guessed. New helper halacha_quality.derive_authority; surfaced read-only
  in list_halachot / goldset_list / search results.
- rule_type — now the rule ROLE only: holding/interpretive/procedural/
  application/obiter. Both extractor prompts unified to this vocabulary;
  _coerce_halacha no longer defaults rule_type from the source; legacy
  binding→holding / persuasive→interpretive fold for safety.

UI: authority shown as a separate read-only badge (gold=מחייב / muted=משכנע)
across the review queue, precedent detail, and gold-set; the gold-set role
selector drops binding/persuasive and adds מהותי (holding).

Migration: scripts/halacha_rule_role_backfill.py re-classifies the 276 pre-split
binding/persuasive rows into a genuine role via local claude_session (run after
deploy). Gold-set correct_type/ai_correct_type 'binding'→'holding' via SQL.

Sources (≥3, per research-decision policy): OASIS LegalRuleML v1.0
(appliesAuthority/Strength as metadata orthogonal to rule logic) · SemEval-2023
Task 6 LegalEval (rhetorical roles by function, authority kept separate) ·
Bluebook signals (weight-of-authority is a separate dimension).

Invariants: ESTABLISHES INV-DM7. Upholds G1 (normalize at source — extractor
classifies role, system derives authority) and G2 (single source of truth —
authority derived, not a parallel stored field). Tests: 211 pass + new
derive_authority/coerce coverage. web-ui build + tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:18:41 +00:00

952 lines
34 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 } 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, 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 };
// ─── 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,
});
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setDraft({
rule_statement: h.rule_statement,
reasoning_summary: h.reasoning_summary,
});
}, [h.id, h.rule_statement, h.reasoning_summary]);
const onSubmitEdit = async () => {
await onSave(draft);
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>
<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>
{(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() {
// #84.1 — "clean" = quality-gated + prioritized + clustered review queue;
// "needsfix" = the flagged 'needs extraction fix' bucket.
const [view, setView] = useState<"clean" | "needsfix">("clean");
const { data, isPending, error } = useHalachotPending({
limit: 500, needsFix: view === "needsfix",
});
const update = useUpdateHalacha();
const batch = useBatchReviewHalachot();
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [focusedId, setFocusedId] = useState<string | null>(null);
const groups = useMemo<Group[]>(
() => buildGroups(data?.items ?? []),
[data],
);
const totalCount = data?.items.length ?? 0;
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 viewToggle = (
<div className="flex items-center gap-1 rounded-lg border border-rule p-0.5 bg-rule-soft/30 w-fit">
<Button
size="sm"
variant={view === "clean" ? "default" : "ghost"}
className={view === "clean" ? "bg-gold text-navy hover:bg-gold-deep" : ""}
onClick={() => setView("clean")}
>
תור נקי
</Button>
<Button
size="sm"
variant={view === "needsfix" ? "default" : "ghost"}
className={view === "needsfix" ? "bg-gold text-navy hover:bg-gold-deep" : ""}
onClick={() => setView("needsfix")}
>
דורש תיקון-חילוץ
</Button>
</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 === "needsfix"
? "בקט התיקון ריק — אין הלכות מסומנות-איכות."
: "אין הלכות נקיות הממתינות לאישור."}
</p>
<p className="text-sm mt-2">
{view === "needsfix"
? "פריטים שסומנו (ציטוט לא-מאומת, יישום, כפילות-קרובה וכו') יופיעו כאן."
: "העלה פסיקה חדשה — ההלכות שיחולצו ממנה יופיעו כאן."}
</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 === "needsfix" ? " מסומנות" : " ממתינות"}
{" "}ב-<span className="text-navy font-semibold">{groups.length}</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>
);
}