Files
legal-ai/web-ui/src/components/cases/legal-arguments-panel.tsx
Chaim f1e9103723
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 9s
feat(ui): WS6 — party accordion, full-width compose, status-hero overview (#206)
שלושת שינויי-ה-UI של WS6 לפי המוקאפים המאושרים (25b/03b/18b, X17):

1. טאב "טיעונים" (legal-arguments-panel) — אקורדיון ברמת-הצד, סגור
   כברירת-מחדל (type="multiple" ללא defaultValue). כל trigger מציג
   שם-צד + תת-כותרת + באדג'-מונה. בתוך כל צד נשמר הקיבוץ-לפי-קדימות
   והאקורדיון הפנימי ברמת-הטיעון.

2. טאב "עמדות וטענות" (compose) — רוחב-דף מלא: בוטל ה-grid 1fr/320px,
   ראיל "מסמכי התיק" הוסר (DocumentsPanel בסקירה הוא הבעלים היחיד)
   ועבר לפס-מסמכים מתקפל למעלה (סגור כברירת-מחדל). כרטיסי-הסוגיות
   2-up. פסיקה-מצורפת ושערי-הייצוא/העלאה שומרו (relocate, לא remove).
   מפת-StatusChip המקבילה הוסרה → StatusBadge המשותף (G2).

3. סטטוס רק בסקירה (overview) — StatusHero חדש (stepper אופקי + chip
   שלב-נוכחי + שינוי-סטטוס ידני + meta-strip), הבעלים היחיד של
   הסטטוס. WorkflowTimeline/StatusChanger/AgentStatusWidget ירדו משאר
   הטאבים; הטאבים האחרים רוחב-מלא. StatusBadge read-only נשאר ב-H1.

Invariants: INV-IA1 (בעלים-יחיד לסטטוס=hero), INV-IA3/G10 (כל שער-אנוש
נשמר במקום אחד — שינוי-סטטוס בסקירה, ייצוא/העלאה ב-compose),
INV-IA4 (compose=מרחב-עבודה ממוקד-משימה רוחב-מלא), INV-IA6 (מפת-סטטוסים
progressive-disclosure), INV-UI7/UI8, G2 (StatusBadge משותף, אין
מפת-סטטוס מקבילה).

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

401 lines
14 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",
};
// Per-party colour mark + one-line role description (mockup 25b party trigger).
const PARTY_MARK_TONE: Record<LegalArgumentParty, string> = {
appellant: "bg-info",
respondent: "bg-gold-deep",
committee: "bg-success",
permit_applicant: "bg-warn",
unknown: "bg-ink-muted",
};
const PARTY_SUB_HE: Record<LegalArgumentParty, string> = {
appellant: "הצד היוזם של הערר",
respondent: "הצד המשיב לערר",
committee: "עמדת הגורם המאשר",
permit_applicant: "צד שלישי שזכותו עשויה להיפגע",
unknown: "צד שלא זוהה אוטומטית",
};
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 = {
args: LegalArgument[];
};
/**
* Inner body of a single party — the priority-grouping + per-argument inner
* accordion. The party header (name / sub / count) now lives on the enclosing
* party-accordion trigger (mockup 25b), so this renders the groups only.
*/
function PartySection({ args }: PartySectionProps) {
const grouped = useMemo(() => groupByPriority(args), [args]);
return (
<div className="space-y-4">
{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>
) : (
// Top-level party accordion — each party is its own collapsible section,
// closed by default (empty defaultValue). The count badge on every
// trigger shows the scope without expanding (mockup 25b).
<Accordion type="multiple" className="space-y-3.5">
{parties.map((party) => {
const args = data.by_party[party] ?? [];
return (
<AccordionItem
key={party}
value={party}
className="overflow-hidden rounded-lg border border-rule bg-surface shadow-sm"
>
<AccordionTrigger className="px-5 py-4 hover:no-underline data-[state=open]:border-b data-[state=open]:border-rule-soft data-[state=open]:bg-parchment">
<span className="flex flex-1 items-center gap-3.5">
<span
className={`h-8 w-1.5 flex-none rounded ${PARTY_MARK_TONE[party]}`}
aria-hidden
/>
<span className="flex flex-1 flex-col items-start gap-0.5">
<span className="text-navy text-base font-bold leading-tight">
{PARTY_LABELS_HE[party] ?? party}
</span>
<span className="text-ink-muted text-xs">
{PARTY_SUB_HE[party]}
</span>
</span>
<Badge
variant="outline"
className="bg-gold-wash text-gold-deep border-rule rounded-full px-3 py-0.5 text-xs font-bold whitespace-nowrap"
>
{args.length} טיעונים
</Badge>
</span>
</AccordionTrigger>
<AccordionContent className="px-5 pb-4 pt-4">
<PartySection args={args} />
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
)}
</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>
);
}