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>
This commit is contained in:
@@ -11,6 +11,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { usePrecedent } from "@/lib/api/precedent-library";
|
||||
import { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet";
|
||||
import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot";
|
||||
import { RelatedCasesSection } from "@/components/precedents/link-related-dialog";
|
||||
|
||||
const PRACTICE_AREA_LABELS: Record<string, string> = {
|
||||
rishuy_uvniya: "רישוי ובנייה",
|
||||
@@ -152,6 +153,15 @@ export default function PrecedentDetailPage({
|
||||
</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 ?? []} />
|
||||
|
||||
222
web-ui/src/components/precedents/link-related-dialog.tsx
Normal file
222
web-ui/src/components/precedents/link-related-dialog.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -84,9 +84,20 @@ export type Halacha = {
|
||||
precedent_level?: string;
|
||||
};
|
||||
|
||||
export type RelatedCase = {
|
||||
id: string;
|
||||
case_number: string;
|
||||
case_name: string;
|
||||
court: string;
|
||||
precedent_level: string;
|
||||
date: string | null;
|
||||
relation_type: string;
|
||||
};
|
||||
|
||||
export type PrecedentDetail = Precedent & {
|
||||
full_text: string;
|
||||
halachot: Halacha[];
|
||||
related_cases: RelatedCase[];
|
||||
};
|
||||
|
||||
export type SearchHit =
|
||||
@@ -357,6 +368,40 @@ export function useDeletePrecedent() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useLinkRelatedCase(caseId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (vars: { relatedId: string; relationType?: string }) =>
|
||||
apiRequest<{ linked: boolean }>(
|
||||
`/api/precedent-library/${encodeURIComponent(caseId)}/relations`,
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
related_id: vars.relatedId,
|
||||
relation_type: vars.relationType ?? "same_case_chain",
|
||||
},
|
||||
},
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: libraryKeys.detail(caseId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnlinkRelatedCase(caseId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (relatedId: string) =>
|
||||
apiRequest<{ unlinked: boolean }>(
|
||||
`/api/precedent-library/${encodeURIComponent(caseId)}/relations/${encodeURIComponent(relatedId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: libraryKeys.detail(caseId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type PrecedentPatch = Partial<{
|
||||
case_name: string;
|
||||
court: string;
|
||||
|
||||
Reference in New Issue
Block a user