ui(precedents): upload sheet routes ערר/בל"מ to internal-decisions endpoint
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
Citations starting with ערר/בל"מ/ARAR are committee decisions and must carry chair_name + district. The /precedents upload form previously errored out for these (precedent_library service rejects them) with no in-UI path forward — internal_decision_upload was only reachable via the /missing-precedents flow. The form now auto-detects committee citations, reveals chair_name + district fields, hides the irrelevant source_type/precedent_level (derived server-side), and posts to /api/internal-decisions/upload. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,8 +16,9 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import {
|
import {
|
||||||
useUploadPrecedent, libraryKeys,
|
useUploadPrecedent, useUploadInternalDecision, libraryKeys,
|
||||||
type PracticeArea, type SourceType,
|
isCommitteeCitation, COMMITTEE_DISTRICTS,
|
||||||
|
type PracticeArea, type SourceType, type CommitteeDistrict,
|
||||||
} from "@/lib/api/precedent-library";
|
} from "@/lib/api/precedent-library";
|
||||||
import { useProgress } from "@/lib/api/documents";
|
import { useProgress } from "@/lib/api/documents";
|
||||||
import {
|
import {
|
||||||
@@ -45,8 +46,15 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
|
|||||||
const [headnote, setHeadnote] = useState("");
|
const [headnote, setHeadnote] = useState("");
|
||||||
const [isBinding, setIsBinding] = useState(true);
|
const [isBinding, setIsBinding] = useState(true);
|
||||||
|
|
||||||
|
// Appeals-committee decisions go to /api/internal-decisions/upload and
|
||||||
|
// require chair_name + district. Routing is by citation prefix.
|
||||||
|
const [chairName, setChairName] = useState("");
|
||||||
|
const [district, setDistrict] = useState<CommitteeDistrict | "">("");
|
||||||
|
const isCommittee = isCommitteeCitation(citation);
|
||||||
|
|
||||||
const [taskId, setTaskId] = useState<string | null>(null);
|
const [taskId, setTaskId] = useState<string | null>(null);
|
||||||
const upload = useUploadPrecedent();
|
const upload = useUploadPrecedent();
|
||||||
|
const uploadInternal = useUploadInternalDecision();
|
||||||
const progress = useProgress(taskId);
|
const progress = useProgress(taskId);
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
@@ -63,6 +71,8 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
|
|||||||
setPracticeArea(""); setAppealSubtype(""); setSubjectTags("");
|
setPracticeArea(""); setAppealSubtype(""); setSubjectTags("");
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setHeadnote(""); setIsBinding(true); setTaskId(null);
|
setHeadnote(""); setIsBinding(true); setTaskId(null);
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setChairName(""); setDistrict("");
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
// Auto-close on completion + refresh library list/stats so the new
|
// Auto-close on completion + refresh library list/stats so the new
|
||||||
@@ -93,11 +103,39 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
|
|||||||
toast.error("מראה המקום (citation) חובה");
|
toast.error("מראה המקום (citation) חובה");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const tags = subjectTags
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tags = subjectTags
|
if (isCommittee) {
|
||||||
.split(",")
|
if (!chairName.trim()) {
|
||||||
.map((t) => t.trim())
|
toast.error("שם יו\"ר חובה להחלטת ועדת ערר");
|
||||||
.filter(Boolean);
|
return;
|
||||||
|
}
|
||||||
|
if (!district) {
|
||||||
|
toast.error("מחוז חובה להחלטת ועדת ערר");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await uploadInternal.mutateAsync({
|
||||||
|
file,
|
||||||
|
case_number: citation.trim(),
|
||||||
|
chair_name: chairName.trim(),
|
||||||
|
district,
|
||||||
|
case_name: caseName.trim(),
|
||||||
|
court: court.trim(),
|
||||||
|
decision_date: decisionDate || undefined,
|
||||||
|
practice_area: practiceArea,
|
||||||
|
appeal_subtype: appealSubtype.trim(),
|
||||||
|
subject_tags: tags,
|
||||||
|
is_binding: isBinding,
|
||||||
|
summary: headnote.trim(),
|
||||||
|
});
|
||||||
|
setTaskId(res.task_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await upload.mutateAsync({
|
const res = await upload.mutateAsync({
|
||||||
file,
|
file,
|
||||||
citation: citation.trim(),
|
citation: citation.trim(),
|
||||||
@@ -119,6 +157,7 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isProcessing = taskId !== null && progress?.status !== "completed" && progress?.status !== "failed";
|
const isProcessing = taskId !== null && progress?.status !== "completed" && progress?.status !== "failed";
|
||||||
|
const isSubmitting = upload.isPending || uploadInternal.isPending;
|
||||||
const stage = (progress as { stage?: string; percent?: number; step?: string } | null)?.stage;
|
const stage = (progress as { stage?: string; percent?: number; step?: string } | null)?.stage;
|
||||||
const percent = (progress as { percent?: number } | null)?.percent ?? 0;
|
const percent = (progress as { percent?: number } | null)?.percent ?? 0;
|
||||||
|
|
||||||
@@ -154,8 +193,45 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
|
|||||||
placeholder={`עע"מ 3975/22 ב. קרן-נכסים נ' ועדה מקומית`}
|
placeholder={`עע"מ 3975/22 ב. קרן-נכסים נ' ועדה מקומית`}
|
||||||
disabled={isProcessing} dir="rtl"
|
disabled={isProcessing} dir="rtl"
|
||||||
/>
|
/>
|
||||||
|
{isCommittee && (
|
||||||
|
<p className="text-[0.72rem] text-ink-muted">
|
||||||
|
זוהתה כהחלטת ועדת ערר — נדרשים שם יו"ר ומחוז (למטה).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isCommittee && (
|
||||||
|
<div className="grid grid-cols-2 gap-3 rounded-md border border-gold/40 bg-gold-wash/40 p-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="chair-name">שם יו"ר (חובה)</Label>
|
||||||
|
<Input
|
||||||
|
id="chair-name" value={chairName}
|
||||||
|
onChange={(e) => setChairName(e.target.value)}
|
||||||
|
placeholder='עו"ד פלוני אלמוני'
|
||||||
|
disabled={isProcessing} dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="district">מחוז (חובה)</Label>
|
||||||
|
<Select
|
||||||
|
value={district || "_none"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setDistrict(v === "_none" ? "" : (v as CommitteeDistrict))
|
||||||
|
}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_none">—</SelectItem>
|
||||||
|
{COMMITTEE_DISTRICTS.map((d) => (
|
||||||
|
<SelectItem key={d} value={d}>{d}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<details className="group rounded-md border border-rule bg-rule-soft/30">
|
<details className="group rounded-md border border-rule bg-rule-soft/30">
|
||||||
<summary className="cursor-pointer select-none px-3 py-2 text-[0.78rem] text-ink-muted hover:text-navy">
|
<summary className="cursor-pointer select-none px-3 py-2 text-[0.78rem] text-ink-muted hover:text-navy">
|
||||||
אופציונלי — דריסה ידנית של שדות שיחולצו אוטומטית מהמסמך
|
אופציונלי — דריסה ידנית של שדות שיחולצו אוטומטית מהמסמך
|
||||||
@@ -206,36 +282,38 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
{!isCommittee && (
|
||||||
<div className="space-y-1">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Label htmlFor="source-type">סוג מקור</Label>
|
<div className="space-y-1">
|
||||||
<Select value={sourceType || "_none"}
|
<Label htmlFor="source-type">סוג מקור</Label>
|
||||||
onValueChange={(v) => setSourceType(v === "_none" ? "" : v as SourceType)}
|
<Select value={sourceType || "_none"}
|
||||||
disabled={isProcessing}>
|
onValueChange={(v) => setSourceType(v === "_none" ? "" : v as SourceType)}
|
||||||
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
|
disabled={isProcessing}>
|
||||||
<SelectContent>
|
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
|
||||||
<SelectItem value="_none">—</SelectItem>
|
<SelectContent>
|
||||||
{SOURCE_TYPES.map((s) => (
|
<SelectItem value="_none">—</SelectItem>
|
||||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
{SOURCE_TYPES.map((s) => (
|
||||||
))}
|
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="precedent-level">רמת תקדים</Label>
|
||||||
|
<Select value={precedentLevel || "_none"}
|
||||||
|
onValueChange={(v) => setPrecedentLevel(v === "_none" ? "" : v)}
|
||||||
|
disabled={isProcessing}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_none">—</SelectItem>
|
||||||
|
{PRECEDENT_LEVELS.map((l) => (
|
||||||
|
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
)}
|
||||||
<Label htmlFor="precedent-level">רמת תקדים</Label>
|
|
||||||
<Select value={precedentLevel || "_none"}
|
|
||||||
onValueChange={(v) => setPrecedentLevel(v === "_none" ? "" : v)}
|
|
||||||
disabled={isProcessing}>
|
|
||||||
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="_none">—</SelectItem>
|
|
||||||
{PRECEDENT_LEVELS.map((l) => (
|
|
||||||
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="tags">תגיות נושא (מופרדות בפסיקים)</Label>
|
<Label htmlFor="tags">תגיות נושא (מופרדות בפסיקים)</Label>
|
||||||
@@ -286,11 +364,11 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
|
|||||||
|
|
||||||
<div className="flex gap-2 justify-end pt-2">
|
<div className="flex gap-2 justify-end pt-2">
|
||||||
<Button type="button" variant="ghost"
|
<Button type="button" variant="ghost"
|
||||||
onClick={() => onOpenChange(false)} disabled={upload.isPending}>
|
onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||||
ביטול
|
ביטול
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit"
|
<Button type="submit"
|
||||||
disabled={upload.isPending || isProcessing}
|
disabled={isSubmitting || isProcessing}
|
||||||
className="bg-navy text-parchment hover:bg-navy-soft">
|
className="bg-navy text-parchment hover:bg-navy-soft">
|
||||||
<Upload className="w-4 h-4 me-1" />
|
<Upload className="w-4 h-4 me-1" />
|
||||||
העלה
|
העלה
|
||||||
|
|||||||
@@ -355,6 +355,85 @@ export function useUploadPrecedent() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Valid Hebrew districts for appeals-committee decisions. Mirrors
|
||||||
|
// VALID_DISTRICTS in mcp-server/src/legal_mcp/tools/internal_decisions.py —
|
||||||
|
// keep in sync with the service-side guard.
|
||||||
|
export const COMMITTEE_DISTRICTS = [
|
||||||
|
"ירושלים",
|
||||||
|
"תל אביב",
|
||||||
|
"מרכז",
|
||||||
|
"חיפה",
|
||||||
|
"צפון",
|
||||||
|
"דרום",
|
||||||
|
"ארצי",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type CommitteeDistrict = (typeof COMMITTEE_DISTRICTS)[number];
|
||||||
|
|
||||||
|
// A citation that targets internal_decision_upload, not the external library.
|
||||||
|
// Matches the prefix list in precedent_library service (ערר/בל"מ/ARAR).
|
||||||
|
const COMMITTEE_PREFIXES = ["ערר ", "ערר(", "בל\"מ ", "בל\"מ(", "ARAR "];
|
||||||
|
|
||||||
|
export function isCommitteeCitation(citation: string): boolean {
|
||||||
|
const c = citation.trimStart();
|
||||||
|
return COMMITTEE_PREFIXES.some((p) => c.startsWith(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InternalDecisionUploadInput = {
|
||||||
|
file: File;
|
||||||
|
case_number: string;
|
||||||
|
chair_name: string;
|
||||||
|
district: CommitteeDistrict | string;
|
||||||
|
case_name?: string;
|
||||||
|
court?: string;
|
||||||
|
decision_date?: string;
|
||||||
|
practice_area?: PracticeArea;
|
||||||
|
appeal_subtype?: string;
|
||||||
|
subject_tags?: string[];
|
||||||
|
is_binding?: boolean;
|
||||||
|
summary?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useUploadInternalDecision() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: InternalDecisionUploadInput) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", input.file);
|
||||||
|
fd.append("case_number", input.case_number);
|
||||||
|
fd.append("chair_name", input.chair_name);
|
||||||
|
fd.append("district", input.district);
|
||||||
|
if (input.case_name) fd.append("case_name", input.case_name);
|
||||||
|
if (input.court) fd.append("court", input.court);
|
||||||
|
if (input.decision_date) fd.append("decision_date", input.decision_date);
|
||||||
|
if (input.practice_area) fd.append("practice_area", input.practice_area);
|
||||||
|
if (input.appeal_subtype)
|
||||||
|
fd.append("appeal_subtype", input.appeal_subtype);
|
||||||
|
if (input.subject_tags && input.subject_tags.length)
|
||||||
|
fd.append("subject_tags", JSON.stringify(input.subject_tags));
|
||||||
|
fd.append("is_binding", String(input.is_binding ?? false));
|
||||||
|
if (input.summary) fd.append("summary", input.summary);
|
||||||
|
|
||||||
|
const res = await fetch("/api/internal-decisions/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
const parsed = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(
|
||||||
|
`Upload failed with ${res.status}`,
|
||||||
|
res.status,
|
||||||
|
parsed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parsed as { task_id: string };
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: libraryKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useDeletePrecedent() {
|
export function useDeletePrecedent() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
Reference in New Issue
Block a user