10 שגיאות (כולן קיימות-מראש, לא מהפיצ'רים האחרונים): - react/no-unescaped-entities (3): legal-arguments-panel, precedent-edit-sheet — escaping של מרכאות ב-JSX (“/") - react-hooks/set-state-in-effect (6): documents-panel, chair-editor, content-checklists, discussion-rules, golden-ratios, documents.ts — disable-comment לדפוסי sync/reset לגיטימיים (false-positive ידוע) - React Compiler reassign (1): subject-donut — refactor לחישוב prefix-sums ללא mutable accumulator ניקוי: הסרת 5 eslint-disable directives מיותרים (halacha-review-panel, precedent-upload-sheet). תוצאה: 0 errors (היה 10), 24→ warnings (היה 29). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
223 lines
7.5 KiB
TypeScript
223 lines
7.5 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo } from "react";
|
||
import {
|
||
Accordion,
|
||
AccordionContent,
|
||
AccordionItem,
|
||
AccordionTrigger,
|
||
} from "@/components/ui/accordion";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent } from "@/components/ui/card";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import {
|
||
PARTY_LABELS_HE,
|
||
PRIORITY_LABELS_HE,
|
||
PRIORITY_ORDER,
|
||
useAggregateArguments,
|
||
useLegalArguments,
|
||
type LegalArgument,
|
||
type LegalArgumentParty,
|
||
type LegalArgumentPriority,
|
||
} from "@/lib/api/legal-arguments";
|
||
import { toast } from "sonner";
|
||
import { 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 && (
|
||
<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) => {
|
||
aggregate.mutate(force, {
|
||
onSuccess: () => {
|
||
toast.success(
|
||
force
|
||
? "הופעלה חזרה חישוב טיעונים (force). יסתיים תוך דקה."
|
||
: "הופעל חישוב טיעונים. רענן בעוד דקה.",
|
||
);
|
||
},
|
||
onError: (e) => toast.error(`שגיאה: ${(e as Error).message}`),
|
||
});
|
||
};
|
||
|
||
return (
|
||
<Card className="bg-surface border-rule shadow-sm">
|
||
<CardContent className="px-6 py-5 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>
|
||
|
||
{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">
|
||
אין טיעונים מאוגדים עדיין. לחץ “חשב טיעונים” כדי להריץ את ה-aggregator.
|
||
</p>
|
||
) : (
|
||
<div className="space-y-6">
|
||
{parties.map((party) => (
|
||
<PartySection
|
||
key={party}
|
||
party={party}
|
||
args={data.by_party[party] ?? []}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|