Files
legal-ai/web-ui/src/app/precedents/[id]/page.tsx
Chaim 5f1b96ccaf feat(graph): navigation & UX — deep-link, depth, PNG, rich panel (PR D)
Final corpus-graph PR. Connects the graph to the chair's workflow and rounds
out the Obsidian-grade interactions.

Backend (web/graph_api.py): neighborhood depth cap 2 → 3 (still bounded by
NODE_CAP_MAX).

Frontend:
- URL deep-link: /graph?focus=cl:<id> is read on mount and written on focus
  change (router.replace, scroll:false). GraphView wrapped in <Suspense> per
  Next 16's useSearchParams requirement.
- "הצג בגרף" button on the precedent detail page → /graph?focus=cl:<id>.
- Depth slider (1–3) in the focused overlay → useNodeNeighborhood(id, depth).
- Export PNG: grabs the rendered <canvas> from the area ref → toDataURL →
  download; failures surface a toast (UI4).
- Rich node panel: precedent nodes fetch headnote/summary via the existing
  usePrecedent hook (Skeleton while pending, error surfaced — UI4).
- Edge-type legend (ציטוט / נושא-תחום / יומון) added under the node legend.

Deferred (noted for a later pass): expand-in-place merge, search→camera-center.

web-ui build + lint pass. Invariants: G2 (depth change is read-only), UI4
(PNG + detail errors surfaced, not swallowed). api:types post-deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 04:56:01 +00:00

337 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 { use, useState } from "react";
import Link from "next/link";
import { Pencil, Check, X, Share2 } from "lucide-react";
import { toast } from "sonner";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import {
usePrecedent,
useUpdatePrecedent,
type Precedent,
} from "@/lib/api/precedent-library";
import { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet";
import {
FormattedCitation,
CitationCopyButton,
} from "@/components/precedents/formatted-citation";
import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot";
import { RelatedCasesSection } from "@/components/precedents/link-related-dialog";
const PRACTICE_AREA_LABELS: Record<string, string> = {
rishuy_uvniya: "רישוי ובנייה",
betterment_levy: "היטל השבחה",
compensation_197: "פיצויים (197)",
};
const SOURCE_TYPE_LABELS: Record<string, string> = {
court_ruling: "פסק דין",
appeals_committee: "ועדת ערר",
};
/* Next 16 breaking change: route params are now a Promise.
* The `use()` hook unwraps them inside a client component. */
export default function PrecedentDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const [editing, setEditing] = useState(false);
const { data, isPending, error } = usePrecedent(id);
const update = useUpdatePrecedent();
const [editingCitation, setEditingCitation] = useState(false);
const [citationDraft, setCitationDraft] = useState("");
return (
<AppShell>
<section className="space-y-6" dir="rtl">
<header>
<nav className="text-[0.78rem] text-ink-muted mb-1">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden> · </span>
<Link href="/precedents" className="hover:text-gold-deep">ספריית פסיקה</Link>
<span aria-hidden> · </span>
<span className="text-navy">פרטי פסיקה</span>
</nav>
</header>
{error ? (
<Card className="bg-danger-bg border-danger/40">
<CardContent className="px-6 py-6 text-center space-y-3">
<p className="text-danger font-semibold">שגיאה בטעינת הפסיקה</p>
<p className="text-sm text-ink-muted">{error.message}</p>
<Button asChild variant="outline">
<Link href="/precedents">חזרה לספרייה</Link>
</Button>
</CardContent>
</Card>
) : isPending || !data ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => <Skeleton key={i} className="h-16 w-full" />)}
</div>
) : (
<>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="min-w-0 flex-1">
<h1 className="text-navy text-2xl font-semibold mb-1 leading-tight">
{data.case_name || "—"}
</h1>
<div className="text-ink-muted text-sm font-mono" dir="ltr">
{data.case_number}
</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/graph?focus=cl:${id}`}>
<Share2 className="w-3.5 h-3.5 me-1" /> הצג בגרף
</Link>
</Button>
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
<Pencil className="w-3.5 h-3.5 me-1" /> ערוך פרטים
</Button>
</div>
</div>
{/* Citation per Israeli unified citation rules. The LLM
extractor composes this from the document; the chair
can override below. */}
<CitationBlock
precedent={data as Precedent}
editing={editingCitation}
draft={citationDraft}
onStartEdit={() => {
setCitationDraft(data.citation_formatted ?? "");
setEditingCitation(true);
}}
onCancel={() => setEditingCitation(false)}
onChange={setCitationDraft}
onSave={async () => {
try {
await update.mutateAsync({
id,
patch: { citation_formatted: citationDraft.trim() },
});
toast.success("מראה מקום עודכן");
setEditingCitation(false);
} catch (e) {
toast.error(
e instanceof Error ? e.message : "שמירה נכשלה",
);
}
}}
saving={update.isPending}
/>
<div className="flex items-center gap-2 flex-wrap">
{data.practice_area ? (
<Badge variant="outline" className="text-[0.7rem]">
{PRACTICE_AREA_LABELS[data.practice_area] ?? data.practice_area}
</Badge>
) : null}
{data.source_type ? (
<Badge variant="outline" className="text-[0.7rem]">
{SOURCE_TYPE_LABELS[data.source_type] ?? data.source_type}
</Badge>
) : null}
{data.precedent_level ? (
<Badge variant="outline" className="text-[0.7rem]">
{data.precedent_level}
</Badge>
) : null}
{data.is_binding ? (
<Badge
variant="outline"
className="text-[0.7rem] bg-gold-wash text-gold-deep border-gold/40"
>
הלכה מחייבת
</Badge>
) : null}
{data.court ? (
<span className="text-[0.78rem] text-ink-muted">{data.court}</span>
) : null}
{data.date ? (
<span className="text-[0.78rem] text-ink-muted tabular-nums" dir="ltr">
{data.date.slice(0, 10)}
</span>
) : null}
</div>
{data.headnote ? (
<div>
<h3 className="text-navy text-sm font-semibold m-0 mb-1">Headnote</h3>
<p className="text-ink-soft text-sm leading-relaxed m-0">
{data.headnote}
</p>
</div>
) : null}
{data.summary ? (
<div>
<h3 className="text-navy text-sm font-semibold m-0 mb-1">תקציר</h3>
<p className="text-ink-soft text-sm leading-relaxed m-0 whitespace-pre-line">
{data.summary}
</p>
</div>
) : null}
{(data as { key_quote?: string }).key_quote ? (
<div>
<h3 className="text-navy text-sm font-semibold m-0 mb-1">ציטוט מרכזי</h3>
<blockquote className="text-ink-soft text-sm leading-relaxed border-r-2 border-gold pr-3 m-0">
{(data as { key_quote?: string }).key_quote}
</blockquote>
</div>
) : null}
{data.subject_tags?.length ? (
<div className="flex items-center gap-1 flex-wrap pt-1">
{data.subject_tags.map((t) => (
<Badge key={t} variant="outline" className="text-[0.65rem]">
{t}
</Badge>
))}
</div>
) : null}
</CardContent>
</Card>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<RelatedCasesSection
caseId={id}
related={data.related_cases ?? []}
/>
</CardContent>
</Card>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<ExtractedHalachotSection halachot={data.halachot ?? []} />
</CardContent>
</Card>
</>
)}
<PrecedentEditSheet
caseLawId={editing ? id : null}
onOpenChange={(open) => setEditing(open)}
/>
</section>
</AppShell>
);
}
function CitationBlock({
precedent,
editing,
draft,
onStartEdit,
onCancel,
onChange,
onSave,
saving,
}: {
precedent: Precedent;
editing: boolean;
draft: string;
onStartEdit: () => void;
onCancel: () => void;
onChange: (v: string) => void;
onSave: () => void;
saving: boolean;
}) {
const citation = (precedent.citation_formatted ?? "").trim();
if (editing) {
return (
<div className="rounded-md border border-gold/40 bg-gold-wash/30 p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-[0.78rem] font-semibold text-navy">
עריכת מראה מקום
</span>
<span className="text-[0.7rem] text-ink-muted">
הקף את שמות הצדדים בכפול-כוכבית <code className="font-mono">**שם**</code> להדגשה
</span>
</div>
<Textarea
value={draft}
onChange={(e) => onChange(e.target.value)}
rows={3}
dir="rtl"
className="font-mono text-sm"
placeholder='ערר (ועדות ערר ...) 1234/24 **עורר נ&apos; הוועדה המקומית** (נבו 1.2.2025)'
disabled={saving}
/>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={onSave}
disabled={saving || !draft.trim()}
className="bg-navy text-parchment hover:bg-navy-soft"
>
<Check className="w-3.5 h-3.5 me-1" />
שמור
</Button>
<Button
size="sm"
variant="outline"
onClick={onCancel}
disabled={saving}
>
<X className="w-3.5 h-3.5 me-1" />
ביטול
</Button>
</div>
</div>
);
}
if (!citation) {
return (
<div className="rounded-md border border-dashed border-rule bg-rule-soft/30 p-3 flex items-center justify-between gap-2">
<span className="text-[0.78rem] text-ink-muted">
מראה מקום (כללי הציטוט האחיד) טרם חולץ
</span>
<Button size="sm" variant="outline" onClick={onStartEdit}>
<Pencil className="w-3.5 h-3.5 me-1" />
הוסף ידנית
</Button>
</div>
);
}
return (
<div className="rounded-md border border-rule bg-parchment-50 p-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-[0.7rem] uppercase tracking-wide text-ink-muted">
מראה מקום
</span>
<div className="flex items-center gap-1.5">
<CitationCopyButton citation={citation} size="xs" />
<button
type="button"
onClick={onStartEdit}
title="ערוך מראה מקום"
aria-label="ערוך מראה מקום"
className="inline-flex items-center justify-center rounded-md border border-rule bg-surface hover:bg-rule-soft/50 text-ink-muted hover:text-navy h-7 w-7"
>
<Pencil className="w-3.5 h-3.5" />
</button>
</div>
</div>
<FormattedCitation
citation={citation}
className="block text-navy text-sm leading-relaxed"
/>
</div>
);
}