Files
legal-ai/web-ui/src/components/precedents/link-related-dialog.tsx
Chaim 3e14cd6798 feat: link related precedents across court instances (SCHEMA_V11)
Add ability to mark case_law records as related (e.g. same appeal
through ועדת ערר → מנהלי → עליון):
- DB: case_law_relations join table (bidirectional, V11 migration)
- DB CRUD: add/remove/get_case_law_relations
- Service: get_precedent() now returns related_cases[]
- MCP: precedent_link_cases + precedent_unlink_cases tools
- REST: POST/DELETE /api/precedent-library/{id}/relations
- UI: RelatedCasesSection on detail page with search dialog and unlink

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 07:52:29 +00:00

223 lines
7.7 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,
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,
} 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>
</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>
);
}
// ── Related Case Card ────────────────────────────────────────────────
function RelatedCaseCard({ caseId, related }: { caseId: string; related: RelatedCase }) {
const { mutateAsync: unlinkCase, isPending } = useUnlinkRelatedCase(caseId);
async function handleUnlink() {
try {
await unlinkCase(related.id);
toast.success("הקישור הוסר");
} catch {
toast.error("שגיאה בהסרת הקישור");
}
}
return (
<div className="flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg border border-rule bg-surface">
<a
href={`/precedents/${related.id}`}
className="min-w-0 flex-1 hover:opacity-80 transition-opacity"
>
<div className="text-sm font-medium text-navy truncate">
{related.case_name || related.case_number}
</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{related.precedent_level && (
<Badge
variant="outline"
className={`text-[0.62rem] ${LEVEL_COLORS[related.precedent_level] ?? ""}`}
>
{LEVEL_LABELS[related.precedent_level] ?? related.precedent_level}
</Badge>
)}
{related.court && (
<span className="text-[0.7rem] text-ink-muted truncate">{related.court}</span>
)}
{related.date && (
<span className="text-[0.7rem] text-ink-muted tabular-nums" dir="ltr">
{related.date.slice(0, 10)}
</span>
)}
</div>
</a>
<button
onClick={handleUnlink}
disabled={isPending}
className="p-1 rounded hover:bg-danger-bg hover:text-danger transition-colors text-ink-muted disabled:opacity-40 shrink-0"
title="הסר קישור"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
);
}
// ── Public section component ─────────────────────────────────────────
type SectionProps = {
caseId: string;
related: RelatedCase[];
};
export function RelatedCasesSection({ caseId, related }: SectionProps) {
const [dialogOpen, setDialogOpen] = useState(false);
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-navy text-sm font-semibold">
החלטות קשורות{related.length > 0 ? ` (${related.length})` : ""}
</h3>
<Button variant="outline" size="sm" onClick={() => setDialogOpen(true)}>
<Link2 className="w-3.5 h-3.5 me-1" /> קשר החלטה
</Button>
</div>
{related.length === 0 ? (
<p className="text-ink-muted text-sm">אין החלטות קשורות עדיין</p>
) : (
<div className="space-y-1.5">
{related.map((r) => (
<RelatedCaseCard key={r.id} caseId={caseId} related={r} />
))}
</div>
)}
<LinkDialog
caseId={caseId}
currentRelated={related}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</div>
);
}