@@ -1,500 +0,0 @@
"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" ;
import { AuthorityBadge } from "@/components/precedents/halacha-meta" ;
// rule ROLE only (INV-DM7) — authority (binding/persuasive) is a SEPARATE,
// derived axis, shown read-only and never tagged here.
const TYPES : { value : string ; label : string } [ ] = [
{ value : "holding" , label : "מהותי" } ,
{ value : "interpretive" , label : "פרשני" } ,
{ value : "procedural" , label : "פרוצדורלי" } ,
{ value : "application" , label : "יישום" } ,
{ value : "obiter" , label : "אמרת-אגב" } ,
] ;
// Consistency between is_holding and the role (#81.7): a real holding is
// holding/interpretive/procedural; a NON-holding is its reason —
// application (fact-bound) or obiter (not decided). Other pairings contradict.
const HOLDING_TYPES = new Set ( [ "holding" , "interpretive" , "procedural" ] ) ;
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) ────────────────────────────────────────────
// Role only — "כמה מחייב" (מחייב/משכנע) is the SEPARATE authority axis, derived
// automatically from the court's identity and shown as a read-only badge.
const TYPE_HELP : { label : string ; def : string ; test : string ; example : string } [ ] = [
{
label : "מהותי" ,
def : "העיקרון המהותי שהיה הכרחי להכרעה — ה-ratio האמיתי. בר-הסתמכות מלא." ,
test : "מבחן וומבו: הפוך את הכלל — אם התוצאה הייתה משתנה → מהותי." ,
example : "נטל ההוכחה בהיטל השבחה מוטל על הוועדה המקומית." ,
} ,
{
label : "פרשני" ,
def : "קביעה שמפרשת הוראת-חוק / מונח / תכנית (מה המשמעות של סעיף X)." ,
test : "עונה ל'מה פירוש הנורמה?' ולא ל'מה הדין?'." ,
example : "תכלית הפטור לפי ס ' 19(ב)(4) היא לעודד פעילות ציבורית." ,
} ,
{
label : "פרוצדורלי" ,
def : "כלל סדר-דין: מועדים, סמכות, זכות-עמידה, מיצוי הליכים, נטל." ,
test : "עוסק ב'איך' מתנהל ההליך, לא במהות התכנונית." ,
example : "המועד להגשת ערר הוא 30 יום." ,
} ,
{
label : "יישום" ,
def : "החלת כלל על עובדות התיק הספציפי — תלוי-עובדות, לא בר-הכללה (לרוב 'לא הלכה')." ,
test : "מכיל 'במקרה דנן', שמות-צדדים, סכומים, המבנה הקונקרטי." ,
example : "במקרה דנן ההיתר בטל כי השומה שגתה ב-12,000 ₪." ,
} ,
{
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" >
כ ל ל - אצבע : סימנת & ldquo ; ה ל כ ה & rdquo ; → ל ר ו ב מ ה ו ת י / פ ר ש נ י / פ ר ו צ ד ו ר ל י . ס י מ נ ת & ldquo ; ל א & rdquo ; → ל ר ו ב י י ש ו ם / א מ ר ת - א ג ב .
< / 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 >
< AuthorityBadge authority = { it . authority } / >
{ 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" >
& ldquo ; { it . supporting_quote } & rdquo ;
< / 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 >
) ;
}