Files
legal-ai/web-ui/src/components/precedents/extracted-halachot.tsx
Chaim 59ff4e31cf
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 38s
feat(halacha): כפתורי אישור/דחייה/שחזור inline ברכיב "הלכות שחולצו"
ExtractedHalachotSection היה read-only — הוסף כפתורי פעולה לכל הלכה לפי
review_status: נדחתה → אשר/שחזר לתור · מאושרת → בטל אישור/דחה ·
ממתינה → אשר/דחה. משתמש ב-useUpdateHalacha שמרענן את detail query.

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

286 lines
11 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 { Check, X, RotateCcw } 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";
const RULE_TYPE_LABELS: Record<string, string> = {
binding: "הלכה מחייבת",
interpretive: "פרשני",
procedural: "פרוצדורלי",
obiter: "אמרת אגב",
application: "יישום הלכה",
persuasive: "משכנע",
};
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]">
{RULE_TYPE_LABELS[h.rule_type] ?? h.rule_type}
</Badge>
<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>
</>
)}
{h.review_status === "pending_review" && (
<>
<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>
<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>
</>
)}
</div>
</li>
))}
</ol>
)}
</div>
);
}