The old independent toggles had a trap: clicking "אי-הסכמות AI" set a filter, and once all disagreements were resolved the toggle button disappeared (rendered only when count>0) while the filter stayed ON — so the list showed zero items and the untagged ones were unreachable. Replaced hideTagged + disagreeOnly with one mutually-exclusive segmented control: הכל / לא תויגו / תויגו / ⚠ אי-הסכמות, each with a live count and always visible. No stuck state; "לא תויגו" makes the remaining work obvious. Verified: tsc --noEmit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
502 lines
24 KiB
TypeScript
502 lines
24 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { Check, X, ChevronDown, ChevronLeft, Info, AlertTriangle } 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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||
import {
|
||
useGoldset, useGoldsetScore, useTagGoldset, useCreateGoldsetSample,
|
||
type GoldsetItem,
|
||
} from "@/lib/api/goldset";
|
||
|
||
const TYPES: { value: string; label: string }[] = [
|
||
{ value: "binding", label: "מחייבת" },
|
||
{ value: "interpretive", label: "פרשני" },
|
||
{ value: "application", label: "יישום" },
|
||
{ value: "obiter", label: "אמרת-אגב" },
|
||
{ value: "procedural", label: "פרוצדורלי" },
|
||
{ value: "persuasive", label: "משכנע" },
|
||
];
|
||
|
||
// Consistency between is_holding and the type (#81.7): a real holding is
|
||
// binding/interpretive/procedural/persuasive; a NON-holding is its reason —
|
||
// application (fact-bound) or obiter (not decided). Other pairings contradict.
|
||
const HOLDING_TYPES = new Set(["binding", "interpretive", "procedural", "persuasive"]);
|
||
const NON_HOLDING_TYPES = new Set(["application", "obiter"]);
|
||
|
||
function inconsistentTag(it: GoldsetItem): string | null {
|
||
if (it.is_holding === null || !it.correct_type) return null;
|
||
if (it.is_holding === true && NON_HOLDING_TYPES.has(it.correct_type)) {
|
||
return "סימנת \"הלכה\" אך הסוג הוא יישום/אמרת-אגב — אלה דווקא הסיבות שמשהו אינו הלכה.";
|
||
}
|
||
if (it.is_holding === false && HOLDING_TYPES.has(it.correct_type)) {
|
||
return "סימנת \"לא הלכה\" אך הסוג מציין הלכה (מחייבת/פרשני/…); ל\"לא\" מתאים יישום או אמרת-אגב.";
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const FLAG_LABELS: Record<string, string> = {
|
||
non_decision: "אי-הכרעה", truncated_quote: "ציטוט קטוע", thin_restatement: "ניסוח דק",
|
||
quote_unverified: "ציטוט לא מאומת", nli_unsupported: "כלל לא נגזר", application: "יישום",
|
||
near_duplicate: "כפילות-קרובה", nevo_preamble_leak: "דליפת רציו",
|
||
};
|
||
|
||
function cleanCitation(s: string | null | undefined): string {
|
||
if (!s) return "—";
|
||
return s.replace(/[--]/g, "").trim();
|
||
}
|
||
|
||
// Source separation (פסקי-דין מול החלטות ועדת-ערר) for convenient tagging.
|
||
function sourceLabel(s: string | null): string {
|
||
return s === "court_ruling" ? "פסק-דין"
|
||
: s === "appeals_committee" ? "ועדת ערר" : "אחר";
|
||
}
|
||
const SOURCE_FILTERS: { value: "all" | "court_ruling" | "appeals_committee"; label: string }[] = [
|
||
{ value: "all", label: "הכל" },
|
||
{ value: "court_ruling", label: "פסקי דין" },
|
||
{ value: "appeals_committee", label: "ועדת ערר" },
|
||
];
|
||
|
||
function isTagged(it: GoldsetItem): boolean {
|
||
// Fully tagged only when ALL THREE answers are set — otherwise, in
|
||
// "hide tagged" mode, a card would vanish the moment is_holding is clicked,
|
||
// before correct_type / quote_complete can be set.
|
||
return it.is_holding !== null && it.quote_complete !== null && !!it.correct_type;
|
||
}
|
||
|
||
// The AI second-opinion disagrees with the human tag (on is_holding or type).
|
||
function aiDisagrees(it: GoldsetItem): boolean {
|
||
if (!it.ai_generated_at) return false;
|
||
const holdDiff = it.is_holding !== null && it.ai_is_holding !== null
|
||
&& it.is_holding !== it.ai_is_holding;
|
||
const typeDiff = !!it.correct_type && !!it.ai_correct_type
|
||
&& it.correct_type !== it.ai_correct_type;
|
||
return holdDiff || typeDiff;
|
||
}
|
||
|
||
// ─── Score panel ──────────────────────────────────────────────────────────────
|
||
|
||
function ScorePanel({ batch }: { batch: string }) {
|
||
const { data } = useGoldsetScore(batch);
|
||
const [open, setOpen] = useState(true);
|
||
if (!data || data.labeled === 0) return null;
|
||
const rows = Object.entries(data.validators);
|
||
// negatives so far (truly "not a holding") = tp+fn of any validator.
|
||
const af = data.validators.any_flag;
|
||
const negatives = af ? af.tp + af.fn : 0;
|
||
return (
|
||
<div className="rounded-lg border border-rule bg-surface">
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((v) => !v)}
|
||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm hover:bg-gold-wash/30"
|
||
aria-expanded={open}
|
||
>
|
||
{open ? <ChevronDown className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
||
<span className="font-semibold text-navy">ציון הוולידטורים מול התיוג</span>
|
||
<span className="text-ink-muted">({data.labeled} מתויגות)</span>
|
||
</button>
|
||
{open && (
|
||
<div className="px-4 pb-3 overflow-x-auto">
|
||
<p className="text-[0.72rem] text-ink-muted mb-2">
|
||
המדדים מודדים זיהוי <strong>"לא-הלכה"</strong> (יישום / ציטוט-קטוע / אי-הכרעה...).
|
||
{negatives < 10
|
||
? ` עד כה תויגו רק ${negatives} פריטי "לא הלכה" — המספרים יהפכו משמעותיים ככל שיצטברו עוד (במיוחד מבקט המסומנים).`
|
||
: " precision גבוה = מעט אזעקות-שווא; recall גבוה = תופס את רוב ה'לא-הלכה'."}
|
||
</p>
|
||
<table className="w-full text-sm tabular-nums">
|
||
<thead>
|
||
<tr className="text-ink-muted text-[0.72rem] border-b border-rule">
|
||
<th className="text-start py-1 ps-1">ולידטור</th>
|
||
<th className="text-start">Precision</th>
|
||
<th className="text-start">Recall</th>
|
||
<th className="text-start">F1</th>
|
||
<th className="text-start">tp/fp/fn/tn</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map(([name, v]) => (
|
||
<tr key={name} className="border-b border-rule-soft last:border-0">
|
||
<td className="py-1 ps-1 text-navy">{name}</td>
|
||
<td>{v.precision.toFixed(2)}</td>
|
||
<td>{v.recall.toFixed(2)}</td>
|
||
<td className="font-semibold">{v.f1.toFixed(2)}</td>
|
||
<td className="text-ink-muted text-[0.72rem]">
|
||
{v.tp}/{v.fp}/{v.fn}/{v.tn}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Rule-type help (info popover) ────────────────────────────────────────────
|
||
|
||
const TYPE_HELP: { label: string; def: string; test: string; example: string }[] = [
|
||
{
|
||
label: "מחייבת",
|
||
def: "העיקרון שהיה הכרחי להכרעה — ה-holding האמיתי. בר-הסתמכות מלא.",
|
||
test: "מבחן וומבו: הפוך את הכלל — אם התוצאה הייתה משתנה → מחייבת.",
|
||
example: "נטל ההוכחה בהיטל השבחה מוטל על הוועדה המקומית.",
|
||
},
|
||
{
|
||
label: "פרשני",
|
||
def: "קביעה שמפרשת הוראת-חוק / מונח / תכנית (מה המשמעות של סעיף X).",
|
||
test: "עונה ל'מה פירוש הנורמה?' ולא ל'מה הדין?'.",
|
||
example: "תכלית הפטור לפי ס' 19(ב)(4) היא לעודד פעילות ציבורית.",
|
||
},
|
||
{
|
||
label: "יישום",
|
||
def: "החלת כלל על עובדות התיק הספציפי — תלוי-עובדות, לא בר-הכללה (לרוב 'לא הלכה').",
|
||
test: "מכיל 'במקרה דנן', שמות-צדדים, סכומים, המבנה הקונקרטי.",
|
||
example: "במקרה דנן ההיתר בטל כי השומה שגתה ב-12,000 ₪.",
|
||
},
|
||
{
|
||
label: "אמרת-אגב",
|
||
def: "נאמר אגב אורחא, לא הכרחי להכרעה; הערכאה לא הכריעה בו. לא מחייב.",
|
||
test: "מבחן וומבו הפוך: היפוך הכלל לא משנה את התוצאה. דגלים: 'למעלה מן הצורך', 'מבלי לקבוע מסמרות'.",
|
||
example: "אף שאיננו נדרשים להכריע, נעיר כי ייתכן ש...",
|
||
},
|
||
{
|
||
label: "פרוצדורלי",
|
||
def: "כלל סדר-דין: מועדים, סמכות, זכות-עמידה, מיצוי הליכים, נטל.",
|
||
test: "עוסק ב'איך' מתנהל ההליך, לא במהות התכנונית.",
|
||
example: "המועד להגשת ערר הוא 30 יום.",
|
||
},
|
||
{
|
||
label: "משכנע",
|
||
def: "אסמכתה לא-מחייבת את הערכאה — שכנוע בלבד.",
|
||
test: "מקור שאינו כובל: ועדת-ערר אחרת, דעת-מיעוט, ספרות.",
|
||
example: "ועדת ערר ירושלים מסתמכת על החלטת ועדת ערר ממחוז אחר.",
|
||
},
|
||
];
|
||
|
||
function RuleTypeHelp() {
|
||
return (
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className="inline-flex items-center text-ink-muted hover:text-gold-deep"
|
||
aria-label="הסבר על סוגי ההלכה"
|
||
title="הסבר על הסוגים"
|
||
>
|
||
<Info className="w-3.5 h-3.5" />
|
||
</button>
|
||
</PopoverTrigger>
|
||
<PopoverContent align="start" className="w-[min(92vw,560px)] max-h-[70vh] overflow-y-auto p-0">
|
||
<div className="p-3 border-b border-rule">
|
||
<p className="font-semibold text-navy text-sm">סוגי ההלכה — במה הם נבדלים</p>
|
||
<p className="text-[0.72rem] text-ink-muted mt-0.5">
|
||
כלל-אצבע: סימנת "הלכה" → לרוב מחייבת / פרשני / פרוצדורלי / משכנע. סימנת "לא" → לרוב יישום / אמרת-אגב.
|
||
</p>
|
||
</div>
|
||
<ul className="divide-y divide-rule-soft">
|
||
{TYPE_HELP.map((t) => (
|
||
<li key={t.label} className="p-3 space-y-1" dir="rtl">
|
||
<div className="font-semibold text-navy text-sm">{t.label}</div>
|
||
<div className="text-[0.78rem] text-ink-soft leading-relaxed">{t.def}</div>
|
||
<div className="text-[0.72rem] text-ink-muted"><span className="font-medium">מבחן:</span> {t.test}</div>
|
||
<div className="text-[0.72rem] text-ink-muted"><span className="font-medium">דוגמה:</span> {t.example}</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</PopoverContent>
|
||
</Popover>
|
||
);
|
||
}
|
||
|
||
// ─── Tag card ─────────────────────────────────────────────────────────────────
|
||
|
||
function TagCard({
|
||
it, focused, onTag,
|
||
}: {
|
||
it: GoldsetItem;
|
||
focused: boolean;
|
||
onTag: (tag: { is_holding?: boolean; correct_type?: string; quote_complete?: boolean }) => void;
|
||
}) {
|
||
const tagged = isTagged(it);
|
||
return (
|
||
<div
|
||
data-goldset-id={it.id}
|
||
className={`rounded-lg border bg-surface p-4 space-y-3 transition-colors
|
||
${focused ? "border-gold ring-2 ring-gold/40 shadow-md" : tagged ? "border-rule-soft opacity-70" : "border-rule"}`}
|
||
>
|
||
<div className="flex items-center gap-2 text-[0.72rem] text-ink-muted flex-wrap">
|
||
<span className="font-semibold text-navy">{cleanCitation(it.case_number)}</span>
|
||
<Badge variant="outline"
|
||
className={`text-[0.65rem] ${it.source_type === "court_ruling"
|
||
? "bg-navy-soft/30 text-navy border-navy/30"
|
||
: "bg-gold-wash text-gold-deep border-gold/40"}`}>
|
||
{sourceLabel(it.source_type)}
|
||
</Badge>
|
||
<Badge variant="outline" className="text-[0.65rem]">מכונה: {it.rule_type}</Badge>
|
||
{it.confidence != null && (
|
||
<Badge variant="outline" className="text-[0.65rem] tabular-nums">ביטחון {it.confidence.toFixed(2)}</Badge>
|
||
)}
|
||
{(it.quality_flags ?? []).map((f) => (
|
||
<Badge key={f} variant="outline" className="text-[0.65rem] bg-danger-bg text-danger border-danger/40">
|
||
{FLAG_LABELS[f] ?? f}
|
||
</Badge>
|
||
))}
|
||
{tagged && (
|
||
<Badge variant="outline" className="text-[0.65rem] bg-gold-wash text-gold-deep border-gold/40 ms-auto">
|
||
<Check className="w-3 h-3 me-1" /> תויג
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
|
||
<p className="text-navy font-medium leading-relaxed" dir="rtl">{it.rule_statement}</p>
|
||
<blockquote className="text-ink-soft text-sm leading-relaxed border-r-2 border-gold pr-3" dir="rtl">
|
||
“{it.supporting_quote}”
|
||
</blockquote>
|
||
|
||
{it.ai_generated_at && (() => {
|
||
const aiType = TYPES.find((t) => t.value === it.ai_correct_type)?.label ?? it.ai_correct_type;
|
||
const holdDisagree = it.is_holding !== null && it.ai_is_holding !== null
|
||
&& it.is_holding !== it.ai_is_holding;
|
||
const typeDisagree = !!it.correct_type && !!it.ai_correct_type
|
||
&& it.correct_type !== it.ai_correct_type;
|
||
const anyTag = it.is_holding !== null || !!it.correct_type;
|
||
return (
|
||
<div className={`rounded-md border p-2.5 text-[0.78rem] space-y-1
|
||
${holdDisagree ? "border-amber-400 bg-amber-50" : "border-rule bg-rule-soft/20"}`} dir="rtl">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="font-semibold text-navy">🤖 המלצת AI:</span>
|
||
<span>{it.ai_is_holding ? "הלכה" : "לא הלכה"}</span>
|
||
{aiType && <span className="text-ink-muted">· {aiType}</span>}
|
||
{anyTag && (
|
||
<span className={`ms-auto text-[0.7rem] px-1.5 py-0.5 rounded
|
||
${holdDisagree || typeDisagree
|
||
? "bg-amber-100 text-amber-800"
|
||
: "bg-emerald-50 text-emerald-700"}`}>
|
||
{holdDisagree ? "⚠ חולק על 'הלכה/לא'"
|
||
: typeDisagree ? "⚠ חולק על הסוג"
|
||
: "✓ מסכים איתך"}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{it.ai_rationale && <div className="text-ink-soft leading-relaxed">{it.ai_rationale}</div>}
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
<div className="grid gap-3 sm:grid-cols-3 pt-1 border-t border-rule-soft">
|
||
{/* is_holding */}
|
||
<div>
|
||
<div className="text-[0.7rem] text-ink-muted mb-1">האם זו הלכה אמיתית?</div>
|
||
<div className="flex gap-1">
|
||
<Button size="sm" variant={it.is_holding === true ? "default" : "ghost"}
|
||
className={it.is_holding === true ? "bg-gold text-navy hover:bg-gold-deep" : ""}
|
||
onClick={() => onTag({ is_holding: true })}>הלכה (H)</Button>
|
||
<Button size="sm" variant={it.is_holding === false ? "default" : "ghost"}
|
||
className={it.is_holding === false ? "bg-danger text-parchment hover:bg-danger" : "text-danger"}
|
||
onClick={() => onTag({ is_holding: false })}>לא (N)</Button>
|
||
</div>
|
||
</div>
|
||
{/* correct_type */}
|
||
<div>
|
||
<div className="text-[0.7rem] text-ink-muted mb-1 flex items-center gap-1">
|
||
הסוג הנכון
|
||
<RuleTypeHelp />
|
||
</div>
|
||
<div className="flex gap-1 flex-wrap">
|
||
{TYPES.map((t) => (
|
||
<Button key={t.value} size="sm"
|
||
variant={it.correct_type === t.value ? "default" : "ghost"}
|
||
className={`text-[0.7rem] px-2 ${it.correct_type === t.value ? "bg-navy text-parchment hover:bg-navy-soft" : ""}`}
|
||
onClick={() => onTag({ correct_type: t.value })}>{t.label}</Button>
|
||
))}
|
||
</div>
|
||
{inconsistentTag(it) && (
|
||
<p className="mt-1 text-[0.68rem] text-amber-700 flex items-start gap-1">
|
||
<AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" />
|
||
{inconsistentTag(it)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
{/* quote_complete */}
|
||
<div>
|
||
<div className="text-[0.7rem] text-ink-muted mb-1">הציטוט שלם?</div>
|
||
<div className="flex gap-1">
|
||
<Button size="sm" variant={it.quote_complete === true ? "default" : "ghost"}
|
||
className={it.quote_complete === true ? "bg-gold text-navy hover:bg-gold-deep" : ""}
|
||
onClick={() => onTag({ quote_complete: true })}>שלם (C)</Button>
|
||
<Button size="sm" variant={it.quote_complete === false ? "default" : "ghost"}
|
||
className={it.quote_complete === false ? "bg-danger text-parchment hover:bg-danger" : "text-danger"}
|
||
onClick={() => onTag({ quote_complete: false })}>קטוע (X)</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Main panel ───────────────────────────────────────────────────────────────
|
||
|
||
export function GoldsetPanel() {
|
||
const batch = "default";
|
||
const { data, isPending, error } = useGoldset(batch);
|
||
const tag = useTagGoldset(batch);
|
||
const createSample = useCreateGoldsetSample(batch);
|
||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||
// Single mutually-exclusive view mode — can't get "stuck" like the old
|
||
// independent toggles (where the disagree filter hid the untagged items).
|
||
const [viewMode, setViewMode] =
|
||
useState<"all" | "untagged" | "tagged" | "disagree">("all");
|
||
const [sourceFilter, setSourceFilter] =
|
||
useState<"all" | "court_ruling" | "appeals_committee">("all");
|
||
|
||
const items = useMemo(() => data?.items ?? [], [data]);
|
||
const taggedCount = items.filter(isTagged).length;
|
||
const untaggedCount = items.length - taggedCount;
|
||
const disagreeCount = items.filter(aiDisagrees).length;
|
||
const sourceCounts = useMemo(() => ({
|
||
court_ruling: items.filter((i) => i.source_type === "court_ruling").length,
|
||
appeals_committee: items.filter((i) => i.source_type === "appeals_committee").length,
|
||
}), [items]);
|
||
const visible = useMemo(() => {
|
||
let v = items;
|
||
if (sourceFilter !== "all") v = v.filter((i) => i.source_type === sourceFilter);
|
||
if (viewMode === "untagged") v = v.filter((i) => !isTagged(i));
|
||
else if (viewMode === "tagged") v = v.filter(isTagged);
|
||
else if (viewMode === "disagree") v = v.filter(aiDisagrees);
|
||
// group-sort: כל פסקי-הדין יחד, ואז כל החלטות ועדת-הערר (הפרדה ברורה).
|
||
const order = (s: string | null) =>
|
||
s === "court_ruling" ? 0 : s === "appeals_committee" ? 1 : 2;
|
||
return [...v].sort((a, b) => order(a.source_type) - order(b.source_type));
|
||
}, [items, viewMode, sourceFilter]);
|
||
|
||
const focused = focusedId ? visible.find((i) => i.id === focusedId) ?? null : null;
|
||
|
||
useEffect(() => {
|
||
if (focusedId && visible.some((i) => i.id === focusedId)) return;
|
||
setFocusedId(visible[0]?.id ?? null);
|
||
}, [focusedId, visible]);
|
||
|
||
useEffect(() => {
|
||
if (!focusedId) return;
|
||
document.querySelector(`[data-goldset-id="${focusedId}"]`)
|
||
?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||
}, [focusedId]);
|
||
|
||
const move = (delta: 1 | -1) => {
|
||
if (!visible.length) return;
|
||
const idx = focusedId ? visible.findIndex((i) => i.id === focusedId) : -1;
|
||
const next = idx < 0 ? (delta > 0 ? 0 : visible.length - 1)
|
||
: Math.max(0, Math.min(visible.length - 1, idx + delta));
|
||
setFocusedId(visible[next].id);
|
||
};
|
||
|
||
const doTag = async (
|
||
it: GoldsetItem,
|
||
t: { is_holding?: boolean; correct_type?: string; quote_complete?: boolean },
|
||
) => {
|
||
try {
|
||
await tag.mutateAsync({ id: it.id, tag: t });
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "שגיאה");
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
const tagName = (e.target as HTMLElement)?.tagName?.toLowerCase();
|
||
if (tagName === "input" || tagName === "textarea" || tagName === "select") return;
|
||
if (e.key === "j") { e.preventDefault(); move(1); }
|
||
else if (e.key === "k") { e.preventDefault(); move(-1); }
|
||
else if (focused && (e.key === "h" || e.key === "H")) { e.preventDefault(); doTag(focused, { is_holding: true }); }
|
||
else if (focused && (e.key === "n" || e.key === "N")) { e.preventDefault(); doTag(focused, { is_holding: false }); }
|
||
else if (focused && (e.key === "c" || e.key === "C")) { e.preventDefault(); doTag(focused, { quote_complete: true }); }
|
||
else if (focused && (e.key === "x" || e.key === "X")) { e.preventDefault(); doTag(focused, { quote_complete: false }); }
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
return () => window.removeEventListener("keydown", onKey);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [focused, visible]);
|
||
|
||
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-40 w-full" />)}</div>;
|
||
}
|
||
if (!items.length) {
|
||
return (
|
||
<div className="text-center text-ink-muted py-16 space-y-4">
|
||
<p className="text-lg">אין מדגם-זהב עדיין.</p>
|
||
<Button disabled={createSample.isPending}
|
||
onClick={() => createSample.mutate(150)}
|
||
className="bg-gold text-navy hover:bg-gold-deep">
|
||
צור מדגם של 150 הלכות לתיוג
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const pct = items.length ? Math.round((taggedCount / items.length) * 100) : 0;
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<ScorePanel batch={batch} />
|
||
|
||
{/* source separation — פסקי-דין מול החלטות ועדת-ערר */}
|
||
<div className="flex items-center gap-1 rounded-lg border border-rule p-0.5 bg-rule-soft/30 w-fit">
|
||
{SOURCE_FILTERS.map((s) => (
|
||
<Button key={s.value} size="sm"
|
||
variant={sourceFilter === s.value ? "default" : "ghost"}
|
||
className={sourceFilter === s.value ? "bg-gold text-navy hover:bg-gold-deep" : ""}
|
||
onClick={() => setSourceFilter(s.value)}>
|
||
{s.label}
|
||
{s.value === "court_ruling" && ` (${sourceCounts.court_ruling})`}
|
||
{s.value === "appeals_committee" && ` (${sourceCounts.appeals_committee})`}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3 flex-wrap text-sm">
|
||
<span className="text-navy font-semibold tabular-nums">{taggedCount}/{items.length} תויגו</span>
|
||
<div className="h-2 w-40 rounded-full bg-rule-soft overflow-hidden">
|
||
<div className="h-full bg-gold" style={{ width: `${pct}%` }} />
|
||
</div>
|
||
<span className="text-ink-muted 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">H</kbd> / לא <kbd className="bg-rule-soft px-1.5 rounded">N</kbd>
|
||
{" "}· ציטוט שלם <kbd className="bg-rule-soft px-1.5 rounded">C</kbd> / קטוע <kbd className="bg-rule-soft px-1.5 rounded">X</kbd>
|
||
</span>
|
||
<div className="ms-auto flex items-center gap-1 rounded-lg border border-rule p-0.5 bg-rule-soft/30">
|
||
{([
|
||
{ v: "all", label: `הכל (${items.length})` },
|
||
{ v: "untagged", label: `לא תויגו (${untaggedCount})` },
|
||
{ v: "tagged", label: `תויגו (${taggedCount})` },
|
||
{ v: "disagree", label: `⚠ אי-הסכמות (${disagreeCount})` },
|
||
] as const).map((m) => (
|
||
<Button key={m.v} size="sm"
|
||
variant={viewMode === m.v ? "default" : "ghost"}
|
||
className={viewMode === m.v
|
||
? (m.v === "disagree" ? "bg-amber-500 text-white hover:bg-amber-600" : "bg-gold text-navy hover:bg-gold-deep")
|
||
: (m.v === "disagree" ? "text-amber-700" : "")}
|
||
onClick={() => setViewMode(m.v)}>
|
||
{m.label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
{visible.map((it) => (
|
||
<TagCard key={it.id} it={it} focused={it.id === focusedId}
|
||
onTag={(t) => doTag(it, t)} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|