Files
legal-ai/web-ui/src/components/cases/hearing-changes-panel.tsx
Chaim ab5d34a9d3
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 59s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
feat(hearing): פאנל "מה קרה בדיון" — חשיפת ניתוח-הפרוטוקול ב-UI (#226)
תוצר analyze_protocol (איך הטענות הכתובות התפתחו בדיון בעל-פה) היה שכבת
ידע-תיק ללא endpoint ותצוגה — נצרך רק downstream ע"י הכותב בבלוק-י. פאנל חדש
בתחתית טאב "טיעונים ועמדות", מתחת לכרטיס-הטיעונים (INV-IA1: לא טאב-מקביל).

Backend:
- SCHEMA_V51: cases.hearing_attendees JSONB — בית קנוני לנוכחות-בדיון
  (panel_members/appellants_present/respondents_present). נבדל סמנטית
  מ-decisions.panel_members (מותב חותם-ההחלטה) → אין מסלול-מקביל (G2).
- protocol_analyzer._extract_header: מאכלס hearing_attendees; refresh על re-run
  (מתקן #223), לא דורס עם חילוץ ריק. אישור-משתמש: "אפשרות א" (persist+הצגה).
- GET /api/cases/{n}/protocol-analysis — header (תאריך+נוכחים קנוניים) + verdicts
  מקובצים לפי צד. קריאה-בלבד, container-safe, ריק≠שגיאה.
- POST /api/cases/{n}/analyze-protocol — מעיר את המנתח (analyze_protocol מריץ
  claude מקומי, נעדר בקונטיינר) כתבנית aggregate-arguments.
- מגע-Paperclip רק דרך agent_platform_port (G12): wake_analyst_for_protocol_analysis.

Frontend:
- lib/api/protocol-analysis.ts (hooks read+trigger) + HearingChangesPanel
  (סרגל-כותרת, פסי-סינון התחזק/נטען-לראשונה, כרטיסים לפי צד, ציטוט-ראיה).
- חיווט לטאב arguments מתחת ל-LegalArgumentsPanel.

עיצוב: מוקאפ 27-case-hearing-changes-panel אושר ע"י חיים דרך שער Claude Design (X17).
Invariants: מקיים G2 (בית קנוני יחיד, לא מסלול-מקביל), G1 (נרמול-במקור),
G12 (שער-הפלטפורמה), INV-IA1 (לא טאב-מקביל), INV-AH (evidence_quote כבר נאכף במקור).
אימות: py_compile + tsc --noEmit + eslint ירוקים; runtime של ה-endpoint = post-deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 12:16:35 +00:00

375 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
ArrowDown,
ArrowUp,
CalendarDays,
HelpCircle,
Link2,
Loader2,
MessageSquareText,
Plus,
RotateCw,
Sparkles,
} from "lucide-react";
import { toast } from "sonner";
import {
CHANGE_LABELS_HE,
PROTOCOL_PARTY_LABELS_HE,
PROTOCOL_PARTY_ORDER,
useAnalyzeProtocol,
useProtocolAnalysis,
type ProtocolAnalysisRow,
type ProtocolChangeType,
type ProtocolPartyRole,
} from "@/lib/api/protocol-analysis";
const PARTY_MARK_TONE: Record<ProtocolPartyRole, string> = {
appellant: "bg-info",
respondent: "bg-gold-deep",
committee: "bg-success",
permit_applicant: "bg-warn",
unknown: "bg-ink-muted",
"": "bg-ink-muted",
};
const CHANGE_BADGE_TONE: Record<ProtocolChangeType, string> = {
strengthened: "bg-gold-wash text-gold-deep border-gold/40",
newly_raised: "bg-emerald-50 text-emerald-900 border-emerald-200",
dropped: "bg-rule-soft text-ink-soft border-rule",
};
const CHANGE_ACCENT: Record<ProtocolChangeType, string> = {
strengthened: "border-s-gold",
newly_raised: "border-s-emerald-500",
dropped: "border-s-rule",
};
function ChangeIcon({ type }: { type: ProtocolChangeType }) {
if (type === "strengthened") return <ArrowUp className="size-3.5" aria-hidden />;
if (type === "newly_raised") return <Plus className="size-3.5" aria-hidden />;
return <ArrowDown className="size-3.5" aria-hidden />;
}
type FilterKey = "all" | ProtocolChangeType;
function AttendeeColumn({
label,
dotTone,
names,
}: {
label: string;
dotTone: string;
names: string[];
}) {
if (!names.length) return null;
return (
<div className="min-w-[210px] flex-1">
<div className="text-gold-deep mb-1.5 flex items-center gap-1.5 text-[0.7rem] font-bold">
<span className={`size-1.5 flex-none rounded-full ${dotTone}`} aria-hidden />
{label}
</div>
<div className="text-ink-soft text-xs leading-relaxed">
{names.join(" · ")}
</div>
</div>
);
}
function DevelopmentCard({ row }: { row: ProtocolAnalysisRow }) {
return (
<div
className={`bg-surface border-rule overflow-hidden rounded-lg border border-s-4 shadow-sm ${CHANGE_ACCENT[row.change_type]}`}
>
<div className="space-y-2.5 px-4 py-3.5">
<div className="flex flex-wrap items-start gap-2.5">
<Badge
variant="outline"
className={`${CHANGE_BADGE_TONE[row.change_type]} flex-none gap-1 text-[0.72rem] font-bold`}
>
<ChangeIcon type={row.change_type} />
{CHANGE_LABELS_HE[row.change_type]}
</Badge>
<span className="text-navy flex-1 text-sm font-bold leading-snug">
{row.argument_title}
</span>
</div>
{row.summary && (
<p className="text-ink-soft text-[0.82rem] leading-relaxed">
{row.summary}
</p>
)}
{row.sharpened_question && (
<div className="bg-parchment border-rule-soft rounded-md border px-3 py-2">
<div className="text-gold-deep mb-1 flex items-center gap-1.5 text-[0.66rem] font-bold">
<HelpCircle className="size-3" aria-hidden />
השאלה שהתחדדה
</div>
<div className="text-ink text-xs leading-relaxed">
{row.sharpened_question}
</div>
</div>
)}
{row.evidence_quote && (
<blockquote className="border-rule text-ink-muted border-s-[3px] px-3 text-xs italic leading-relaxed">
&ldquo;{row.evidence_quote}&rdquo;
<span className="text-ink-muted mt-1 block text-[0.68rem] not-italic font-semibold">
מתוך פרוטוקול הדיון
</span>
</blockquote>
)}
<div className="flex flex-wrap items-center gap-2 pt-0.5">
{row.change_type === "newly_raised" ? (
<span className="text-emerald-800 inline-flex items-center gap-1.5 text-[0.72rem] font-semibold">
<Sparkles className="size-3.5" aria-hidden />
אין מקבילה בכתב עלה לראשונה בדיון
</span>
) : row.argument_id ? (
<span className="text-ink-muted inline-flex items-center gap-1.5 text-[0.72rem] font-semibold">
<Link2 className="size-3.5" aria-hidden />
מבוסס על טיעון כתוב קיים
</span>
) : null}
</div>
</div>
</div>
);
}
type HearingChangesPanelProps = {
caseNumber: string;
};
export function HearingChangesPanel({ caseNumber }: HearingChangesPanelProps) {
const { data, isPending, isError, error } = useProtocolAnalysis(caseNumber);
const analyze = useAnalyzeProtocol(caseNumber);
const [filter, setFilter] = useState<FilterKey>("all");
const handleAnalyze = () => {
analyze.mutate(undefined, {
onSuccess: (res) => {
if (res.status === "queued") {
toast.success("נשלח למנתח המשפטי — הניתוח ירוץ ברקע; רענן בעוד כמה דקות.");
} else {
toast.warning(
`לא ניתן להריץ אוטומטית (${res.reason}). ניתן להריץ ידנית מ-Claude Code.`,
);
}
},
onError: (e) => toast.error(`שגיאה: ${(e as Error).message}`),
});
};
const header = data?.header;
const counts = data?.by_change ?? {};
const strengthened = counts.strengthened ?? 0;
const newlyRaised = counts.newly_raised ?? 0;
const dropped = counts.dropped ?? 0;
const sections = useMemo(() => {
if (!data) return [];
return PROTOCOL_PARTY_ORDER.map((role) => {
const rows = (data.by_party[role] ?? []).filter(
(r) => filter === "all" || r.change_type === filter,
);
return { role, rows };
}).filter((s) => s.rows.length > 0);
}, [data, filter]);
const runButton = (
<Button
variant="outline"
size="sm"
disabled={analyze.isPending}
onClick={handleAnalyze}
>
{analyze.isPending ? (
<Loader2 className="me-1.5 size-3.5 animate-spin" />
) : (
<RotateCw className="me-1.5 size-3.5" />
)}
{data?.total ? "נתח מחדש" : "נתח את פרוטוקול הדיון"}
</Button>
);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-navy flex items-center gap-2 text-base font-semibold">
<MessageSquareText className="text-gold-deep size-5" aria-hidden />
מה קרה בדיון
</h2>
<p className="text-ink-muted mt-0.5 max-w-2xl text-xs leading-relaxed">
השוואה בין הטענות בכתב לבין מה שנטען בדיון בעל-פה אילו טענות התחזקו
ואילו נטענו לראשונה, כדי שהדיון (בלוק י) יתייחס לגלגול העדכני.
</p>
</div>
{runButton}
</div>
{isPending ? (
<div className="space-y-2">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-24 w-full" />
</div>
) : isError ? (
<p className="text-danger text-sm">
שגיאה בטעינת ניתוח-הפרוטוקול: {(error as Error).message}
</p>
) : !data?.total ? (
<div className="bg-surface border-rule rounded-lg border border-dashed px-6 py-8 text-center">
<h3 className="text-navy text-sm font-bold">טרם נותח פרוטוקול</h3>
<p className="text-ink-muted mx-auto mt-1.5 max-w-md text-xs leading-relaxed">
כשיש בתיק פרוטוקול של ועדת-הערר וטיעונים מאוגדים, הניתוח מזהה מה
התחזק ומה נטען לראשונה בדיון. ההרצה נשלחת למנתח המשפטי ורצה ברקע.
</p>
<div className="mt-4 flex justify-center">{runButton}</div>
</div>
) : (
<>
{/* hearing header strip */}
{header && (
<div className="bg-parchment border-rule rounded-lg border px-5 py-4 shadow-sm">
<div className="flex flex-wrap items-center gap-3">
<span className="bg-gold-wash border-rule flex size-9 flex-none items-center justify-center rounded-lg border">
<CalendarDays className="text-gold-deep size-5" aria-hidden />
</span>
<div className="flex-1">
<div className="text-navy text-[0.95rem] font-bold">
{header.protocol_title || "פרוטוקול דיון"}
</div>
<div className="text-ink-muted mt-0.5 text-xs">
{header.hearing_date
? `תאריך דיון: ${header.hearing_date}`
: "תאריך דיון לא זוהה"}
{header.panel_members.length > 0 &&
` · מותב: ${header.panel_members.join(", ")}`}
</div>
</div>
<Badge
variant="outline"
className="bg-info-bg text-info border-info/30 flex-none rounded-full text-[0.72rem] font-semibold"
>
פרוטוקול ועדת-הערר
</Badge>
</div>
{(header.appellants_present.length > 0 ||
header.respondents_present.length > 0) && (
<div className="border-rule-soft mt-3 flex flex-wrap gap-6 border-t pt-3">
<AttendeeColumn
label="נכחו מטעם העוררים"
dotTone="bg-info"
names={header.appellants_present}
/>
<AttendeeColumn
label="נכחו מטעם המשיבים"
dotTone="bg-gold-deep"
names={header.respondents_present}
/>
</div>
)}
</div>
)}
{/* filter pills */}
<div className="flex flex-wrap items-center gap-2">
<FilterPill
active={filter === "all"}
onClick={() => setFilter("all")}
label="הכל"
count={data.total}
/>
{strengthened > 0 && (
<FilterPill
active={filter === "strengthened"}
onClick={() => setFilter("strengthened")}
label="התחזקו"
count={strengthened}
icon={<ArrowUp className="size-3.5" aria-hidden />}
/>
)}
{newlyRaised > 0 && (
<FilterPill
active={filter === "newly_raised"}
onClick={() => setFilter("newly_raised")}
label="נטענו לראשונה"
count={newlyRaised}
icon={<Plus className="size-3.5" aria-hidden />}
/>
)}
{dropped > 0 && (
<FilterPill
active={filter === "dropped"}
onClick={() => setFilter("dropped")}
label="נזנחו"
count={dropped}
icon={<ArrowDown className="size-3.5" aria-hidden />}
/>
)}
</div>
{/* per-party sections */}
{sections.map(({ role, rows }) => (
<div key={role || "unassigned"} className="space-y-2.5">
<div className="flex items-center gap-2.5">
<span
className={`h-5 w-1.5 flex-none rounded ${PARTY_MARK_TONE[role]}`}
aria-hidden
/>
<span className="text-navy text-[0.95rem] font-bold">
{PROTOCOL_PARTY_LABELS_HE[role]}
</span>
<span className="text-ink-muted text-xs">{rows.length}</span>
</div>
<div className="space-y-2.5">
{rows.map((r) => (
<DevelopmentCard key={r.id} row={r} />
))}
</div>
</div>
))}
</>
)}
</div>
);
}
function FilterPill({
active,
onClick,
label,
count,
icon,
}: {
active: boolean;
onClick: () => void;
label: string;
count: number;
icon?: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={`inline-flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors ${
active
? "bg-navy border-navy text-white"
: "bg-surface border-rule text-ink-soft hover:bg-parchment"
}`}
>
{icon}
{label}
<span className="font-bold">{count}</span>
</button>
);
}