שלושת שינויי-ה-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>
401 lines
14 KiB
TypeScript
401 lines
14 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",
|
||
};
|
||
|
||
// 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">
|
||
אין טיעונים מאוגדים עדיין. לחץ “חשב טיעונים” כדי לשלוח את
|
||
החישוב למנתח המשפטי.
|
||
</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>
|
||
);
|
||
}
|