Files
legal-ai/web-ui/src/components/cases/legal-arguments-panel.tsx
Chaim 31029b2d43
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
feat(arguments): inline status banners + fix double-card nesting
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>
2026-06-20 18:35:55 +00:00

356 lines
12 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, 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">
אין טיעונים מאוגדים עדיין. לחץ &ldquo;חשב טיעונים&rdquo; כדי לשלוח את
החישוב למנתח המשפטי.
</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>
);
}