Files
legal-ai/web-ui/src/components/precedents/link-related-dialog.tsx
Chaim 91c521922f
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
feat(precedents): surface auto-detected incoming citations in the "ציטוטים מקושרים" panel
The precedent-detail "ציטוטים מקושרים" panel rendered only MANUAL appeal-chain
links (case_law_relations), so it stayed empty even when decisions cite the
ruling — the automatic citation graph (precedent_internal_citations) was never
surfaced. e.g. עע"מ 317/10 (שפר) has 12 incoming citations in the DB, none shown.

Fills the existing panel from the citation graph (data/logic only, no new
visual design — gate-exempt per web-ui/AGENTS.md):
- citation_extractor.list_citations_to_case_law: return source precedent_level/
  court/date too (additive; reuses the one canonical incoming query — G2).
- precedent_library.get_precedent: add incoming_citations[], shaped like the
  RelatedCase row. No parallel resolution path.
- web-ui: render incoming citations in the same row style, read-only (no unlink),
  de-duped against manual links; manual "קשר" linking preserved alongside.

Invariants: G2 (single citation-resolution path, reused), G1 (graph self-heals
via existing relink_orphan_citations on upload). No schema change.

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

278 lines
10 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 { useState } from "react";
import { Link2, Loader2, X } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
usePrecedents,
useLinkRelatedCase,
useUnlinkRelatedCase,
RelatedCase,
IncomingCitation,
} from "@/lib/api/precedent-library";
const LEVEL_LABELS: Record<string, string> = {
"עליון": "עליון",
"מנהלי": "מנהלי",
"ועדת_ערר_ארצית": "ארצי",
"ועדת_ערר_מחוזית": "מחוזי",
};
const LEVEL_COLORS: Record<string, string> = {
"עליון": "bg-red-50 text-red-700 border-red-200",
"מנהלי": "bg-orange-50 text-orange-700 border-orange-200",
"ועדת_ערר_ארצית": "bg-blue-50 text-blue-700 border-blue-200",
"ועדת_ערר_מחוזית": "bg-green-50 text-green-700 border-green-200",
};
// ── Search Dialog ────────────────────────────────────────────────────
type DialogProps = {
caseId: string;
currentRelated: RelatedCase[];
open: boolean;
onOpenChange: (open: boolean) => void;
};
function LinkDialog({ caseId, currentRelated, open, onOpenChange }: DialogProps) {
const [query, setQuery] = useState("");
const { mutateAsync: linkCase, isPending } = useLinkRelatedCase(caseId);
const alreadyLinkedIds = new Set([...currentRelated.map((r) => r.id), caseId]);
const { data, isPending: searching } = usePrecedents(
query.length >= 2 ? { search: query, limit: 10 } : {},
);
const candidates = (data?.items ?? []).filter((p) => !alreadyLinkedIds.has(p.id));
async function handleLink(relatedId: string) {
try {
await linkCase({ relatedId });
toast.success("הפסיקות קושרו");
setQuery("");
} catch {
toast.error("שגיאה בקישור");
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg" dir="rtl">
<DialogHeader>
<DialogTitle className="text-navy">קשר החלטה קשורה</DialogTitle>
<DialogDescription className="sr-only">חיפוש וקישור תקדים או החלטה קשורה לתיק</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Input
placeholder="חפש לפי מספר תיק, שם, ערכאה..."
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
dir="rtl"
/>
{query.length >= 2 && (
<div className="space-y-1 max-h-72 overflow-y-auto">
{searching ? (
<div className="flex items-center gap-2 text-ink-muted text-sm py-3">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> מחפש...
</div>
) : candidates.length === 0 ? (
<p className="text-ink-muted text-sm py-3 text-center">לא נמצאו תוצאות</p>
) : (
candidates.map((p) => (
<button
key={p.id}
onClick={() => handleLink(p.id)}
disabled={isPending}
className="w-full text-right flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg border border-rule hover:bg-surface/60 transition-colors disabled:opacity-50"
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-navy truncate">
{p.case_name || p.case_number}
</div>
<div className="text-[0.72rem] text-ink-muted font-mono" dir="ltr">
{p.case_number}
</div>
</div>
{p.precedent_level && (
<Badge
variant="outline"
className={`text-[0.62rem] shrink-0 ${LEVEL_COLORS[p.precedent_level] ?? ""}`}
>
{LEVEL_LABELS[p.precedent_level] ?? p.precedent_level}
</Badge>
)}
</button>
))
)}
</div>
)}
{query.length > 0 && query.length < 2 && (
<p className="text-ink-muted text-xs">הקלד לפחות 2 תווים</p>
)}
</div>
</DialogContent>
</Dialog>
);
}
// ── Public section component ─────────────────────────────────────────
type SectionProps = {
caseId: string;
related: RelatedCase[];
/** Decisions that cite THIS ruling — auto-detected from the citation graph
* (read-only). Optional so existing call-sites stay valid. */
incoming?: IncomingCitation[];
};
/* Rail-styled citations card (mockup 08 side rail). Renders linked related
* decisions as a navy-headed card with arrow-prefixed rows; keeps the full
* link/unlink logic. Auto-detected incoming citations (decisions that cite this
* ruling) render in the SAME row style, read-only (no unlink). Used in the
* precedent-detail side rail. */
export function RelatedCasesSection({ caseId, related, incoming = [] }: SectionProps) {
const [dialogOpen, setDialogOpen] = useState(false);
// De-dupe: a decision already linked manually shouldn't appear twice.
const manualIds = new Set(related.map((r) => r.id));
const autoCites = incoming.filter((c) => !manualIds.has(c.id));
const total = related.length + autoCites.length;
return (
<div className="rounded-lg border border-rule bg-surface shadow-sm px-4 py-3.5 space-y-2.5">
<div className="flex items-center justify-between gap-2">
<h3 className="text-navy text-[0.92rem] font-semibold m-0">
ציטוטים מקושרים{total > 0 ? ` (${total})` : ""}
</h3>
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-[0.72rem] border-rule"
onClick={() => setDialogOpen(true)}
>
<Link2 className="w-3 h-3 me-1" /> קשר
</Button>
</div>
{total === 0 ? (
<p className="text-ink-muted text-[0.82rem] m-0">אין החלטות קשורות עדיין</p>
) : (
<ul className="list-none p-0 m-0">
{autoCites.map((c) => (
<li
key={c.id}
className="flex items-start gap-2 py-2 border-b border-rule-soft last:border-b-0"
>
<span className="text-gold font-bold leading-6 shrink-0" aria-hidden></span>
<a
href={`/precedents/${c.id}`}
className="min-w-0 flex-1 group hover:opacity-90 transition-opacity"
>
<div className="text-[0.82rem] text-ink-soft leading-5 group-hover:text-navy">
{c.case_name || c.case_number}
</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{c.precedent_level && (
<Badge
variant="outline"
className={`text-[0.6rem] ${LEVEL_COLORS[c.precedent_level] ?? ""}`}
>
{LEVEL_LABELS[c.precedent_level] ?? c.precedent_level}
</Badge>
)}
{(c.chair_name || c.court) && (
<span className="text-[0.68rem] text-ink-muted truncate">
{c.chair_name || c.court}
</span>
)}
{c.date && (
<span className="text-[0.68rem] text-ink-muted tabular-nums" dir="ltr">
{c.date.slice(0, 10)}
</span>
)}
</div>
</a>
</li>
))}
{related.map((r) => (
<li
key={r.id}
className="flex items-start gap-2 py-2 border-b border-rule-soft last:border-b-0"
>
<span className="text-gold font-bold leading-6 shrink-0" aria-hidden></span>
<a
href={`/precedents/${r.id}`}
className="min-w-0 flex-1 group hover:opacity-90 transition-opacity"
>
<div className="text-[0.82rem] text-ink-soft leading-5 group-hover:text-navy">
{r.case_name || r.case_number}
</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{r.precedent_level && (
<Badge
variant="outline"
className={`text-[0.6rem] ${LEVEL_COLORS[r.precedent_level] ?? ""}`}
>
{LEVEL_LABELS[r.precedent_level] ?? r.precedent_level}
</Badge>
)}
{r.court && (
<span className="text-[0.68rem] text-ink-muted truncate">{r.court}</span>
)}
{r.date && (
<span className="text-[0.68rem] text-ink-muted tabular-nums" dir="ltr">
{r.date.slice(0, 10)}
</span>
)}
</div>
</a>
<UnlinkButton caseId={caseId} relatedId={r.id} />
</li>
))}
</ul>
)}
<LinkDialog
caseId={caseId}
currentRelated={related}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</div>
);
}
function UnlinkButton({ caseId, relatedId }: { caseId: string; relatedId: string }) {
const { mutateAsync: unlinkCase, isPending } = useUnlinkRelatedCase(caseId);
return (
<button
onClick={async () => {
try {
await unlinkCase(relatedId);
toast.success("הקישור הוסר");
} catch {
toast.error("שגיאה בהסרת הקישור");
}
}}
disabled={isPending}
className="p-0.5 rounded hover:bg-danger-bg hover:text-danger transition-colors text-ink-muted disabled:opacity-40 shrink-0"
title="הסר קישור"
>
<X className="w-3 h-3" />
</button>
);
}