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>
223 lines
7.7 KiB
TypeScript
223 lines
7.7 KiB
TypeScript
"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>
|
||
);
|
||
}
|