Implements the Claude-Design-approved inline status banners for the "חשב טיעונים" panel (mockup 25-legal-arguments-panel): the four endpoint states (queued / exists / no_claims / skipped) now render as tone-coded banners below the header instead of a transient toast. Toast is kept only for hard transport errors. Also fixes a pre-existing double-card bug found while reviewing the page: the page's "arguments" tab already wraps the panel in <Card><CardContent> (page.tsx:151-155), yet LegalArgumentsPanel rendered its OWN <Card> too — unlike its sibling tab panels (DecisionBlocksPanel/DraftsPanel/ AgentActivityFeed) which render a plain <div>. The panel now matches that convention (single card, no double border/padding), consistent with the approved single-card mockup. Design gate: card 25 approved by חיים. tsc + eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, type ReactNode } from "react";
|
||
import {
|
||
Accordion,
|
||
AccordionContent,
|
||
AccordionItem,
|
||
AccordionTrigger,
|
||
} from "@/components/ui/accordion";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Popover,
|
||
PopoverContent,
|
||
PopoverTrigger,
|
||
} from "@/components/ui/popover";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import {
|
||
PARTY_LABELS_HE,
|
||
PRIORITY_LABELS_HE,
|
||
PRIORITY_ORDER,
|
||
useAggregateArguments,
|
||
useLegalArguments,
|
||
type AggregateArgumentsResult,
|
||
type LegalArgument,
|
||
type LegalArgumentParty,
|
||
type LegalArgumentPriority,
|
||
} from "@/lib/api/legal-arguments";
|
||
import { toast } from "sonner";
|
||
import { ListTree, Loader2, RefreshCw, Sparkles } from "lucide-react";
|
||
|
||
const PRIORITY_BADGE_TONE: Record<LegalArgumentPriority, string> = {
|
||
threshold: "bg-danger-bg/60 text-danger-strong border-danger/40",
|
||
substantive: "bg-gold-soft/50 text-navy border-gold/40",
|
||
procedural: "bg-rule-soft text-ink border-rule",
|
||
relief: "bg-emerald-50 text-emerald-900 border-emerald-200",
|
||
};
|
||
|
||
function groupByPriority(
|
||
args: LegalArgument[],
|
||
): Record<LegalArgumentPriority, LegalArgument[]> {
|
||
const out: Record<LegalArgumentPriority, LegalArgument[]> = {
|
||
threshold: [],
|
||
substantive: [],
|
||
procedural: [],
|
||
relief: [],
|
||
};
|
||
for (const a of args) {
|
||
(out[a.priority] ?? out.substantive).push(a);
|
||
}
|
||
for (const key of PRIORITY_ORDER) {
|
||
out[key].sort((x, y) => x.argument_index - y.argument_index);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
type PartySectionProps = {
|
||
party: LegalArgumentParty;
|
||
args: LegalArgument[];
|
||
};
|
||
|
||
function PartySection({ party, args }: PartySectionProps) {
|
||
const grouped = useMemo(() => groupByPriority(args), [args]);
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex items-baseline justify-between border-b border-rule pb-2">
|
||
<h3 className="text-navy text-base font-semibold">
|
||
{PARTY_LABELS_HE[party] ?? party}
|
||
</h3>
|
||
<span className="text-ink-muted text-xs">
|
||
{args.length} טיעונים
|
||
</span>
|
||
</div>
|
||
{PRIORITY_ORDER.map((priority) => {
|
||
const list = grouped[priority];
|
||
if (!list?.length) return null;
|
||
return (
|
||
<div key={priority} className="space-y-1">
|
||
<div className="flex items-center gap-2">
|
||
<Badge
|
||
variant="outline"
|
||
className={`${PRIORITY_BADGE_TONE[priority]} text-xs`}
|
||
>
|
||
{PRIORITY_LABELS_HE[priority]}
|
||
</Badge>
|
||
<span className="text-ink-muted text-xs">
|
||
{list.length} טיעונים
|
||
</span>
|
||
</div>
|
||
<Accordion type="multiple" className="rounded-md border border-rule bg-surface">
|
||
{list.map((arg) => (
|
||
<AccordionItem key={arg.id} value={arg.id} className="px-3">
|
||
<AccordionTrigger className="text-start">
|
||
<div className="flex flex-1 flex-col items-start gap-1">
|
||
<span className="text-navy text-sm font-medium leading-tight">
|
||
{arg.argument_index}. {arg.argument_title}
|
||
</span>
|
||
{arg.legal_topic && (
|
||
<span className="text-ink-muted text-xs">
|
||
{arg.legal_topic}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</AccordionTrigger>
|
||
<AccordionContent>
|
||
<div className="space-y-2 px-1">
|
||
<p className="text-ink leading-relaxed whitespace-pre-line">
|
||
{arg.argument_body}
|
||
</p>
|
||
{arg.supporting_claims.length > 0 &&
|
||
(arg.supporting_propositions &&
|
||
arg.supporting_propositions.length > 0 ? (
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className="text-gold-strong hover:text-gold inline-flex items-center gap-1 text-xs underline decoration-dotted underline-offset-2"
|
||
>
|
||
<ListTree className="size-3.5" aria-hidden />
|
||
מסתמך על {arg.supporting_claims.length}{" "}
|
||
פרופוזיציות גולמיות
|
||
</button>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
align="start"
|
||
className="max-h-96 w-96 overflow-y-auto"
|
||
>
|
||
<p className="text-ink-muted mb-2 text-xs font-medium">
|
||
הטענות הגולמיות שמהן אוגד הטיעון:
|
||
</p>
|
||
<ol className="space-y-2">
|
||
{arg.supporting_propositions.map((p, i) => (
|
||
<li
|
||
key={p.id}
|
||
className="border-rule border-s-2 ps-2 text-xs"
|
||
>
|
||
<span className="text-navy font-medium">
|
||
{i + 1}.
|
||
</span>{" "}
|
||
<span className="text-ink leading-relaxed">
|
||
{p.text}
|
||
</span>
|
||
{p.source_document && (
|
||
<span className="text-ink-muted mt-0.5 block">
|
||
מקור: {p.source_document}
|
||
</span>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</PopoverContent>
|
||
</Popover>
|
||
) : (
|
||
<p className="text-ink-muted text-xs">
|
||
מסתמך על {arg.supporting_claims.length} פרופוזיציות
|
||
גולמיות.
|
||
</p>
|
||
))}
|
||
</div>
|
||
</AccordionContent>
|
||
</AccordionItem>
|
||
))}
|
||
</Accordion>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type LegalArgumentsPanelProps = {
|
||
caseNumber: string;
|
||
};
|
||
|
||
export function LegalArgumentsPanel({ caseNumber }: LegalArgumentsPanelProps) {
|
||
const { data, isPending, isError, error } = useLegalArguments(caseNumber);
|
||
const aggregate = useAggregateArguments(caseNumber);
|
||
|
||
const parties = useMemo<LegalArgumentParty[]>(() => {
|
||
if (!data?.by_party) return [];
|
||
const order: LegalArgumentParty[] = [
|
||
"appellant",
|
||
"respondent",
|
||
"committee",
|
||
"permit_applicant",
|
||
"unknown",
|
||
];
|
||
return order.filter((p) => (data.by_party[p]?.length ?? 0) > 0);
|
||
}, [data]);
|
||
|
||
const handleAggregate = (force: boolean) => {
|
||
// Status feedback is rendered inline as a banner from `aggregate.data`;
|
||
// only hard transport/HTTP errors fall through to a toast.
|
||
aggregate.mutate(force, {
|
||
onError: (e) => toast.error(`שגיאה: ${(e as Error).message}`),
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||
<div>
|
||
<h2 className="text-navy text-base font-semibold">טיעונים משפטיים</h2>
|
||
<p className="text-ink-muted text-xs mt-0.5">
|
||
טיעונים מאוגדים מתוך הטענות הגולמיות, מקובצים לפי צד וקדימות. החישוב
|
||
רץ אצל המנתח המשפטי ומדווח בחזרה.
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={aggregate.isPending}
|
||
onClick={() => handleAggregate(false)}
|
||
>
|
||
{aggregate.isPending ? (
|
||
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
|
||
) : (
|
||
<Sparkles className="w-3.5 h-3.5 me-1.5" />
|
||
)}
|
||
חשב טיעונים
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
disabled={aggregate.isPending || !data?.total}
|
||
onClick={() => handleAggregate(true)}
|
||
title="חישוב מחדש (מוחק טיעונים קיימים)"
|
||
>
|
||
<RefreshCw className="w-3.5 h-3.5" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<AggregateStatusBanner
|
||
result={aggregate.data}
|
||
force={aggregate.variables ?? false}
|
||
/>
|
||
|
||
{isPending ? (
|
||
<div className="space-y-2">
|
||
<Skeleton className="h-6 w-48" />
|
||
<Skeleton className="h-20 w-full" />
|
||
<Skeleton className="h-20 w-full" />
|
||
</div>
|
||
) : isError ? (
|
||
<p className="text-danger text-sm">
|
||
שגיאה בטעינת טיעונים: {(error as Error).message}
|
||
</p>
|
||
) : !data?.total ? (
|
||
<p className="text-ink-muted text-sm">
|
||
אין טיעונים מאוגדים עדיין. לחץ “חשב טיעונים” כדי לשלוח את
|
||
החישוב למנתח המשפטי.
|
||
</p>
|
||
) : (
|
||
<div className="space-y-6">
|
||
{parties.map((party) => (
|
||
<PartySection
|
||
key={party}
|
||
party={party}
|
||
args={data.by_party[party] ?? []}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const BANNER_TONE = {
|
||
info: "border-info/30 bg-info-bg",
|
||
gold: "border-gold/40 bg-gold-wash",
|
||
muted: "border-rule bg-rule-soft",
|
||
warn: "border-warn/40 bg-warn-bg",
|
||
} as const;
|
||
|
||
const DOT_TONE = {
|
||
info: "bg-info",
|
||
gold: "bg-gold-deep",
|
||
muted: "bg-ink-light",
|
||
warn: "bg-warn",
|
||
} as const;
|
||
|
||
const TITLE_TONE = {
|
||
info: "text-info",
|
||
gold: "text-gold-deep",
|
||
muted: "text-ink-soft",
|
||
warn: "text-warn",
|
||
} as const;
|
||
|
||
/**
|
||
* Inline status feedback for the aggregation trigger — mirrors the approved
|
||
* Claude Design mockup (25-legal-arguments-panel). Shown only after a click;
|
||
* the four states map to the endpoint's discriminated-union result.
|
||
*/
|
||
function AggregateStatusBanner({
|
||
result,
|
||
force,
|
||
}: {
|
||
result: AggregateArgumentsResult | undefined;
|
||
force: boolean;
|
||
}) {
|
||
if (!result) return null;
|
||
|
||
let tone: keyof typeof BANNER_TONE;
|
||
let title: string;
|
||
let body: ReactNode;
|
||
|
||
switch (result.status) {
|
||
case "queued":
|
||
tone = "info";
|
||
title = "נשלח לאנליטיקאי.";
|
||
body = `${force ? "החישוב-מחדש" : "החישוב"} רץ ברקע אצל המנתח המשפטי; התוצאה תופיע תוך כמה דקות — רענן את הדף.`;
|
||
break;
|
||
case "exists":
|
||
tone = "gold";
|
||
title = "כבר חושב.";
|
||
body = result.message;
|
||
break;
|
||
case "no_claims":
|
||
tone = "muted";
|
||
title = "אין טענות גולמיות.";
|
||
body = result.message;
|
||
break;
|
||
case "skipped":
|
||
tone = "warn";
|
||
title = "לא ניתן להפעיל אוטומטית";
|
||
body = (
|
||
<>
|
||
{` (${result.reason}). הרץ ידנית מ-Claude Code: `}
|
||
<code className="font-mono text-[0.72rem] bg-navy/5 rounded px-1.5 py-0.5 select-all">
|
||
mcp__legal-ai__aggregate_claims_to_arguments
|
||
</code>
|
||
</>
|
||
);
|
||
break;
|
||
default:
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={`flex items-start gap-2.5 rounded-md border px-3.5 py-2.5 text-[0.8rem] leading-relaxed text-ink-soft ${BANNER_TONE[tone]}`}
|
||
>
|
||
<span
|
||
className={`mt-1.5 size-2 flex-none rounded-full ${DOT_TONE[tone]}`}
|
||
aria-hidden
|
||
/>
|
||
<p>
|
||
<strong className={`font-semibold ${TITLE_TONE[tone]}`}>{title}</strong>{" "}
|
||
{body}
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|