All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 38s
ExtractedHalachotSection היה read-only — הוסף כפתורי פעולה לכל הלכה לפי review_status: נדחתה → אשר/שחזר לתור · מאושרת → בטל אישור/דחה · ממתינה → אשר/דחה. משתמש ב-useUpdateHalacha שמרענן את detail query. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
"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"
|
||
>
|
||
“{h.supporting_quote}”
|
||
</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>
|
||
);
|
||
}
|