10 שגיאות (כולן קיימות-מראש, לא מהפיצ'רים האחרונים): - react/no-unescaped-entities (3): legal-arguments-panel, precedent-edit-sheet — escaping של מרכאות ב-JSX (“/") - react-hooks/set-state-in-effect (6): documents-panel, chair-editor, content-checklists, discussion-rules, golden-ratios, documents.ts — disable-comment לדפוסי sync/reset לגיטימיים (false-positive ידוע) - React Compiler reassign (1): subject-donut — refactor לחישוב prefix-sums ללא mutable accumulator ניקוי: הסרת 5 eslint-disable directives מיותרים (halacha-review-panel, precedent-upload-sheet). תוצאה: 0 errors (היה 10), 24→ warnings (היה 29). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
805 lines
28 KiB
TypeScript
805 lines
28 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "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";
|
||
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, type Halacha,
|
||
} from "@/lib/api/precedent-library";
|
||
|
||
/** #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: "כלל לא נגזר מהציטוט",
|
||
};
|
||
|
||
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();
|
||
}
|
||
|
||
const RULE_TYPE_LABELS: Record<string, string> = {
|
||
binding: "הלכה מחייבת",
|
||
interpretive: "פרשני",
|
||
procedural: "פרוצדורלי",
|
||
obiter: "אמרת אגב",
|
||
application: "יישום הלכה",
|
||
persuasive: "משכנע",
|
||
};
|
||
|
||
function ruleTypeLabel(t: string): string {
|
||
return RULE_TYPE_LABELS[t] ?? t;
|
||
}
|
||
|
||
type EditState = { rule_statement: string; reasoning_summary: string };
|
||
|
||
// ─── Pending-queue card (full interactions) ───────────────────────────────────
|
||
|
||
function HalachaCard({
|
||
h, focused, onApprove, onReject, onDefer, onSave,
|
||
}: {
|
||
h: Halacha;
|
||
focused: boolean;
|
||
onApprove: () => void;
|
||
onReject: () => void;
|
||
onDefer: () => void;
|
||
onSave: (patch: Partial<EditState>) => Promise<void>;
|
||
}) {
|
||
const [editing, setEditing] = useState(false);
|
||
const [draft, setDraft] = useState<EditState>({
|
||
rule_statement: h.rule_statement,
|
||
reasoning_summary: h.reasoning_summary,
|
||
});
|
||
|
||
useEffect(() => {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
setDraft({
|
||
rule_statement: h.rule_statement,
|
||
reasoning_summary: h.reasoning_summary,
|
||
});
|
||
}, [h.id, h.rule_statement, h.reasoning_summary]);
|
||
|
||
const onSubmitEdit = async () => {
|
||
await onSave(draft);
|
||
setEditing(false);
|
||
};
|
||
|
||
return (
|
||
<div
|
||
data-halacha-id={h.id}
|
||
className={`
|
||
rounded-lg border bg-surface p-4 space-y-3 transition-colors
|
||
${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">
|
||
{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>
|
||
<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>
|
||
<blockquote className="text-ink-soft text-sm leading-relaxed border-r-2 border-gold pr-3" dir="rtl">
|
||
“{h.supporting_quote}”
|
||
</blockquote>
|
||
</div>
|
||
</div>
|
||
|
||
{(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>
|
||
|
||
<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>
|
||
<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 ────────────────────────────────────────────────────────
|
||
|
||
type Group = {
|
||
caseLawId: string;
|
||
caseNumber: string;
|
||
court: string;
|
||
decisionDate: string | null;
|
||
precedentLevel: string;
|
||
items: Halacha[];
|
||
};
|
||
|
||
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);
|
||
|
||
const groups = useMemo<Group[]>(
|
||
() => buildGroups(data?.items ?? []),
|
||
[data],
|
||
);
|
||
|
||
const totalCount = data?.items.length ?? 0;
|
||
|
||
const visibleItems = useMemo<Halacha[]>(() => {
|
||
const out: Halacha[] = [];
|
||
for (const g of groups) {
|
||
if (expandedIds.has(g.caseLawId)) out.push(...g.items);
|
||
}
|
||
return out;
|
||
}, [groups, expandedIds]);
|
||
|
||
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 = 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 (
|
||
h: Halacha,
|
||
status: "approved" | "rejected" | "deferred",
|
||
extra?: Partial<EditState>,
|
||
) => {
|
||
try {
|
||
await update.mutateAsync({
|
||
id: h.id,
|
||
patch: { review_status: status, ...extra },
|
||
});
|
||
toast.success(REVIEW_TOAST[status] ?? "עודכן");
|
||
} 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.map((h) => h.id), 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]);
|
||
|
||
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>
|
||
<p className="text-sm mt-2">העלה פסיקה חדשה — ההלכות שיחולצו ממנה יופיעו כאן.</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<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> ממתינות
|
||
ב-<span className="text-navy font-semibold">{groups.length}</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 = 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.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.items.length} ההלכות:
|
||
</span>
|
||
<Button
|
||
size="sm" variant="ghost" disabled={batch.isPending}
|
||
onClick={() => {
|
||
if (window.confirm(`לדחות את כל ${g.items.length} ההלכות בפסק זה?`))
|
||
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.items.length} ההלכות בפסק זה?`))
|
||
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>
|
||
);
|
||
}
|
||
|
||
// ─── 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>
|
||
);
|
||
}
|