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