עוטף את HearingChangesPanel באקורדיון single/collapsible סגור-כברירת-מחדל, זהה לפאנל-האח "טיעונים מאוגדים לפי צד" מעליו (הנחיית חיים). הכותרת+תיאור עוברים לטריגר-האקורדיון; מוסרת הכותרת הכפולה מהרכיב (כפתור-ההרצה נשמר, מיושר לקצה בתצוגה המלאה). tsc + eslint ירוקים. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
"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,
|
||
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">
|
||
“{row.evidence_quote}”
|
||
<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">
|
||
{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>
|
||
) : (
|
||
<>
|
||
<div className="flex justify-end">{runButton}</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>
|
||
);
|
||
}
|