feat(halacha): טאבים נדחו/אושרו + שחזור הלכה + הסרת placeholders עם שמות
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 43s

- מוסיף טאב "נדחו" לדף האישורים: הלכות שנדחו מופיעות עם כפתורי "אשר" (ישירות) ו-"שחזר לתור"
- מוסיף טאב "אושרו": הלכות שאושרו עם "בטל אישור" ו-"דחה"
- ספירה צבועה על כל טאב (זהב/אדום/כחול)
- מוסיף useHalachotByStatus hook ב-API
- מסיר placeholders עם שמות ("דפנה תמיר") משדות יו"ר

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 12:07:49 +00:00
parent f5926506fe
commit c83d0162ca
4 changed files with 341 additions and 76 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Check, X, Edit2, ChevronDown, ChevronLeft, AlertTriangle, Clock } from "lucide-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";
@@ -10,7 +10,7 @@ import { Textarea } from "@/components/ui/textarea";
import { CorroborationBadge } from "./corroboration-badge";
import { practiceAreaLabel } from "./practice-area";
import {
useHalachotPending, useUpdateHalacha, useBatchReviewHalachot, type Halacha,
useHalachotPending, useHalachotByStatus, useUpdateHalacha, useBatchReviewHalachot, type Halacha,
} from "@/lib/api/precedent-library";
/** #81 strict-rubric flags — why an item was held back from auto-approval. */
@@ -19,21 +19,9 @@ const QUALITY_FLAG_LABELS: Record<string, string> = {
truncated_quote: "ציטוט קטוע",
thin_restatement: "ניסוח דק",
quote_unverified: "ציטוט לא מאומת",
nli_unsupported: "כלל לא נגזר מהציטוט",
};
/**
* Halacha review queue — the chair-only path between automatic
* extraction and agent visibility. Per the project's review policy,
* NO halacha is auto-published; every row sits in pending_review until
* approved.
*
* UX: items are grouped by precedent (case_law_id). Groups start
* collapsed so the chair picks one ruling at a time. Within an open
* group, J/K navigates, A approves, R rejects, E edits. Items inside
* each group are sorted by confidence ascending so the doubtful ones
* surface first.
*/
function formatDate(iso: string | null | undefined) {
if (!iso) return "—";
try {
@@ -43,9 +31,7 @@ function formatDate(iso: string | null | undefined) {
}
}
/* The upload form (and Nevo PDFs) embed Unicode bidi marks (RTL/LTR/embedding/
* isolate) inside the citation. They render as zero-width but visually push
* the text away from where it should sit. Strip for display only. */
/* 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();
@@ -66,6 +52,8 @@ function ruleTypeLabel(t: string): string {
type EditState = { rule_statement: string; reasoning_summary: string };
// ─── Pending-queue card (full interactions) ───────────────────────────────────
function HalachaCard({
h, focused, onApprove, onReject, onDefer, onSave,
}: {
@@ -82,7 +70,6 @@ function HalachaCard({
reasoning_summary: h.reasoning_summary,
});
// Reset draft when underlying row changes (focus moves to a new card).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setDraft({
@@ -104,7 +91,6 @@ function HalachaCard({
${focused ? "border-gold ring-2 ring-gold/40 shadow-md" : "border-rule"}
`}
>
{/* Header — status pills only (citation is in the group header) */}
<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>
@@ -129,7 +115,6 @@ function HalachaCard({
</span>
</div>
{/* #81 quality flags — explain why this item needs a human eye */}
{h.quality_flags && h.quality_flags.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{h.quality_flags.map((f) => (
@@ -142,7 +127,6 @@ function HalachaCard({
</div>
)}
{/* Side-by-side rule vs quote */}
<div className="grid md:grid-cols-2 gap-3">
<div>
<div className="text-[0.7rem] text-ink-muted mb-1">ניסוח הכלל</div>
@@ -166,7 +150,6 @@ function HalachaCard({
</div>
</div>
{/* Reasoning */}
{(editing || h.reasoning_summary) && (
<div>
<div className="text-[0.7rem] text-ink-muted mb-1">תמצית ההיגיון</div>
@@ -184,7 +167,6 @@ function HalachaCard({
</div>
)}
{/* Tags */}
<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">
@@ -198,7 +180,6 @@ function HalachaCard({
))}
</div>
{/* Actions */}
<div className="flex items-center gap-2 justify-end pt-1 border-t border-rule-soft">
{editing ? (
<>
@@ -239,6 +220,78 @@ function HalachaCard({
);
}
// ─── 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>
<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 ────────────────────────────────────────────────────────
type Group = {
caseLawId: string;
caseNumber: string;
@@ -248,44 +301,168 @@ type Group = {
items: Halacha[];
};
export function HalachaReviewPanel() {
function buildGroups(items: Halacha[]): Group[] {
const map = new Map<string, Group>();
for (const h of items) {
const k = h.case_law_id;
let g = map.get(k);
if (!g) {
g = {
caseLawId: k,
caseNumber: h.case_number ?? "",
court: h.court ?? "",
decisionDate: h.decision_date ?? null,
precedentLevel: h.precedent_level ?? "",
items: [],
};
map.set(k, g);
}
g.items.push(h);
}
for (const g of map.values()) {
g.items.sort((a, b) => a.confidence - b.confidence);
}
return Array.from(map.values()).sort((a, b) => b.items.length - a.items.length);
}
// ─── 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.items.length}
</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() {
const { data, isPending, error } = useHalachotPending(500);
const update = useUpdateHalacha();
const batch = useBatchReviewHalachot();
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [focusedId, setFocusedId] = useState<string | null>(null);
// Group by precedent. Within each group, items sorted by confidence ascending
// so the chair sees doubtful entries first. Groups themselves sort by item
// count descending — biggest piles surface first.
const groups = useMemo<Group[]>(() => {
const map = new Map<string, Group>();
for (const h of data?.items ?? []) {
const k = h.case_law_id;
let g = map.get(k);
if (!g) {
g = {
caseLawId: k,
caseNumber: h.case_number ?? "",
court: h.court ?? "",
decisionDate: h.decision_date ?? null,
precedentLevel: h.precedent_level ?? "",
items: [],
};
map.set(k, g);
}
g.items.push(h);
}
for (const g of map.values()) {
g.items.sort((a, b) => a.confidence - b.confidence);
}
return Array.from(map.values()).sort((a, b) => b.items.length - a.items.length);
}, [data]);
const groups = useMemo<Group[]>(
() => buildGroups(data?.items ?? []),
[data],
);
const totalCount = data?.items.length ?? 0;
// Items the keyboard handler can navigate. Only items inside expanded
// groups are "visible" — collapsed groups hide their items from J/K.
const visibleItems = useMemo<Halacha[]>(() => {
const out: Halacha[] = [];
for (const g of groups) {
@@ -294,8 +471,6 @@ export function HalachaReviewPanel() {
return out;
}, [groups, expandedIds]);
// If the focused item disappears (approved/rejected/group-collapsed), pick
// a sensible neighbour so the highlight doesn't vanish silently.
useEffect(() => {
if (focusedId === null) return;
if (visibleItems.some((h) => h.id === focusedId)) return;
@@ -303,7 +478,6 @@ export function HalachaReviewPanel() {
setFocusedId(visibleItems[0]?.id ?? null);
}, [focusedId, visibleItems]);
// Scroll the focused row into view
useEffect(() => {
if (!focusedId) return;
const el = document.querySelector(`[data-halacha-id="${focusedId}"]`);
@@ -345,7 +519,6 @@ export function HalachaReviewPanel() {
}
};
// #84 — one decision applied to a whole precedent group (one request).
const reviewGroup = async (g: Group, status: "approved" | "rejected") => {
try {
const res = await batch.mutateAsync({
@@ -366,7 +539,6 @@ export function HalachaReviewPanel() {
next.delete(caseLawId);
} else {
next.add(caseLawId);
// Auto-focus first item of the just-opened group
const g = groups.find((x) => x.caseLawId === caseLawId);
if (g && g.items.length) {
setTimeout(() => setFocusedId(g.items[0].id), 0);
@@ -376,8 +548,6 @@ export function HalachaReviewPanel() {
});
};
// Keyboard navigation — only acts when something is focused (i.e. at
// least one group is open).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName?.toLowerCase();
@@ -444,10 +614,7 @@ export function HalachaReviewPanel() {
{" "}· עריכה: <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 size="sm" variant="ghost" onClick={() => setExpandedIds(new Set())}>
סגור הכל
</Button>
)}
@@ -457,10 +624,7 @@ export function HalachaReviewPanel() {
{groups.map((g) => {
const isOpen = expandedIds.has(g.caseLawId);
return (
<div
key={g.caseLawId}
className="rounded-lg border border-rule bg-surface overflow-hidden"
>
<div key={g.caseLawId} className="rounded-lg border border-rule bg-surface overflow-hidden">
<button
type="button"
onClick={() => toggleGroup(g.caseLawId)}
@@ -486,17 +650,13 @@ export function HalachaReviewPanel() {
{g.precedentLevel && <span>· {g.precedentLevel}</span>}
</div>
</div>
<Badge
variant="outline"
className="bg-gold-wash text-gold-deep border-gold/40 tabular-nums"
>
<Badge variant="outline" className="bg-gold-wash text-gold-deep border-gold/40 tabular-nums">
{g.items.length} ממתינות
</Badge>
</button>
{isOpen && (
<div className="p-4 space-y-3 bg-rule-soft/20">
{/* #84 — group batch actions: one decision for the whole ruling */}
<div className="flex items-center gap-2 justify-end pb-1">
<span className="me-auto text-[0.72rem] text-ink-muted">
פעולה קבוצתית על {g.items.length} ההלכות:
@@ -551,3 +711,95 @@ export function HalachaReviewPanel() {
</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(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>
);
}

View File

@@ -242,7 +242,7 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
<Label htmlFor="chair-name">יו"ר</Label>
<Input id="chair-name" value={form.chair_name}
onChange={(e) => setForm({ ...form, chair_name: e.target.value })}
placeholder="עו״ד דפנה תמיר" />
placeholder="" />
</div>
<div className="space-y-1">
<Label htmlFor="date">תאריך</Label>