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

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

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

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

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

276 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 { useMemo, useState } from "react";
import Link from "next/link";
import { Check, X, RotateCcw, ArrowLeft } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CorroborationBadge } from "@/components/precedents/corroboration-badge";
import { useUpdateHalacha, type Halacha } from "@/lib/api/precedent-library";
import { AuthorityBadge, ruleTypeLabel } from "./halacha-meta";
type StatusFilter = "all" | "approved" | "pending" | "rejected";
export function ReviewStatusPill({ status }: { status: Halacha["review_status"] }) {
if (status === "approved" || status === "published") {
return (
<Badge
variant="outline"
className="text-[0.65rem] bg-gold-wash text-gold-deep border-gold/40"
>
מאושרת
</Badge>
);
}
if (status === "pending_review") {
return (
<Badge
variant="outline"
className="text-[0.65rem] bg-rule-soft text-ink-muted"
>
ממתינה
</Badge>
);
}
return (
<Badge
variant="outline"
className="text-[0.65rem] bg-danger-bg text-danger border-danger/40"
>
נדחתה
</Badge>
);
}
/* Roll-up of every halacha extracted from a precedent — approved +
* pending + rejected. The "ממתין לאישור" tab surfaces pending items
* globally; this section is the per-case view, with inline status
* actions so the chair can flip a single ruling (e.g. approve one that
* was rejected) without leaving the precedent. */
export function ExtractedHalachotSection({ halachot }: { halachot: Halacha[] }) {
const [filter, setFilter] = useState<StatusFilter>("all");
const update = useUpdateHalacha();
const setStatus = async (
h: Halacha,
status: "approved" | "rejected" | "pending_review",
) => {
try {
await update.mutateAsync({ id: h.id, patch: { review_status: status } });
toast.success("עודכן");
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
};
const counts = useMemo(() => {
const c = { all: halachot.length, approved: 0, pending: 0, rejected: 0 };
for (const h of halachot) {
if (h.review_status === "approved" || h.review_status === "published") {
c.approved++;
} else if (h.review_status === "pending_review") {
c.pending++;
} else if (h.review_status === "rejected") {
c.rejected++;
}
}
return c;
}, [halachot]);
const sorted = useMemo(() => {
const matches = (h: Halacha) => {
if (filter === "all") return true;
if (filter === "approved") {
return h.review_status === "approved" || h.review_status === "published";
}
if (filter === "pending") return h.review_status === "pending_review";
return h.review_status === "rejected";
};
/* Surface citation-corroborated halachot first: any with a badge
* (positive count or a negative treatment) float to the top, ordered
* by corroboration strength; the rest keep document order by index. */
const hasTag = (h: Halacha) =>
(h.corroboration_count ?? 0) > 0 || (h.corroboration_negative ?? false);
return halachot
.filter(matches)
.sort((a, b) => {
const tagDelta = Number(hasTag(b)) - Number(hasTag(a));
if (tagDelta !== 0) return tagDelta;
const countDelta =
(b.corroboration_count ?? 0) - (a.corroboration_count ?? 0);
if (countDelta !== 0) return countDelta;
return a.halacha_index - b.halacha_index;
});
}, [halachot, filter]);
if (!halachot.length) {
return (
<div className="rounded-lg border border-rule bg-rule-soft/40 p-4 text-center text-ink-muted text-sm">
עדיין לא חולצו הלכות מהפסיקה הזו.
</div>
);
}
const tabs: { key: StatusFilter; label: string; count: number }[] = [
{ key: "all", label: "הכל", count: counts.all },
{ key: "approved", label: "מאושרות", count: counts.approved },
{ key: "pending", label: "ממתינות", count: counts.pending },
{ key: "rejected", label: "נדחו", count: counts.rejected },
];
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<h3 className="text-navy text-base font-semibold m-0">
הלכות שחולצו ({counts.all})
</h3>
<div className="flex items-center gap-1 flex-wrap" role="tablist">
{tabs.map((t) => {
const active = filter === t.key;
return (
<button
key={t.key}
type="button"
role="tab"
aria-selected={active}
onClick={() => setFilter(t.key)}
className={`text-[0.78rem] px-2.5 py-1 rounded border transition-colors tabular-nums ${
active
? "bg-navy text-parchment border-navy"
: "bg-surface text-ink-muted border-rule hover:bg-rule-soft"
}`}
>
{t.label} ({t.count})
</button>
);
})}
</div>
</div>
{!sorted.length ? (
<div className="text-center text-ink-muted text-sm py-6">
אין הלכות בקטגוריה זו.
</div>
) : (
<ol className="space-y-3 list-none ps-0 m-0">
{sorted.map((h) => (
<li
key={h.id}
className="rounded-lg border border-rule bg-surface p-3 space-y-2"
>
<div className="flex items-start gap-2 flex-wrap">
<span className="text-[0.7rem] text-ink-muted tabular-nums font-mono">
#{h.halacha_index}
</span>
<ReviewStatusPill status={h.review_status} />
<Badge variant="outline" className="text-[0.65rem]">
{ruleTypeLabel(h.rule_type)}
</Badge>
<AuthorityBadge authority={h.authority} />
<Badge variant="outline" className="text-[0.65rem] tabular-nums">
ביטחון {h.confidence.toFixed(2)}
</Badge>
<CorroborationBadge halacha={h} />
{h.page_reference ? (
<span className="text-[0.7rem] text-ink-muted ms-auto">
{h.page_reference}
</span>
) : null}
</div>
<p
className="text-navy font-medium text-sm leading-relaxed m-0"
dir="rtl"
>
{h.rule_statement}
</p>
{h.reasoning_summary ? (
<p
className="text-ink-soft text-[0.82rem] leading-relaxed m-0"
dir="rtl"
>
<span className="text-ink-muted text-[0.7rem]">היגיון: </span>
{h.reasoning_summary}
</p>
) : null}
{h.supporting_quote ? (
<blockquote
className="text-ink-soft text-[0.82rem] leading-relaxed border-r-2 border-gold pr-3 m-0"
dir="rtl"
>
&ldquo;{h.supporting_quote}&rdquo;
</blockquote>
) : null}
{h.subject_tags?.length ? (
<div className="flex items-center gap-1 flex-wrap pt-1">
{h.subject_tags.map((t) => (
<Badge key={t} variant="outline" className="text-[0.65rem]">
{t}
</Badge>
))}
</div>
) : null}
{/* Inline status actions — contextual per current review_status */}
<div className="flex items-center gap-2 justify-end pt-2 border-t border-rule-soft">
{h.review_status === "rejected" && (
<>
<Button size="sm" variant="ghost" disabled={update.isPending}
onClick={() => setStatus(h, "pending_review")}
className="text-ink-muted hover:text-navy">
<RotateCcw className="w-3.5 h-3.5 me-1" />
שחזר לתור
</Button>
<Button size="sm" disabled={update.isPending}
onClick={() => setStatus(h, "approved")}
className="bg-gold text-navy hover:bg-gold-deep">
<Check className="w-3.5 h-3.5 me-1" />
אשר
</Button>
</>
)}
{(h.review_status === "approved" || h.review_status === "published") && (
<>
<Button size="sm" variant="ghost" disabled={update.isPending}
onClick={() => setStatus(h, "pending_review")}
className="text-ink-muted hover:text-navy">
<RotateCcw className="w-3.5 h-3.5 me-1" />
בטל אישור
</Button>
<Button size="sm" variant="ghost" disabled={update.isPending}
onClick={() => {
if (window.confirm("לדחות הלכה זו?")) setStatus(h, "rejected");
}}
className="text-danger hover:text-danger hover:bg-danger-bg">
<X className="w-3.5 h-3.5 me-1" />
דחה
</Button>
</>
)}
{/* #133 — the chair approves in the unified queue, not here:
a pending halacha links to the queue instead of duplicating the
approve/reject card (single gate, INV-IA/G10). */}
{h.review_status === "pending_review" && (
<Link
href="/precedents?tab=review"
className="inline-flex items-center gap-1.5 text-[0.78rem] font-semibold
text-gold-deep bg-gold-wash border border-gold/40 rounded-md px-3 py-1.5
hover:bg-gold-wash/70 transition-colors"
>
עבור לתור הלכות
<ArrowLeft className="w-3.5 h-3.5" />
</Link>
)}
</div>
</li>
))}
</ol>
)}
</div>
);
}