Files
legal-ai/web-ui/src/components/wizard/case-wizard.tsx
Chaim e8bcb9c1ea
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 6s
fix(cases): מספור 5-ספרתי לבל"מ — סיווג, ולידציה, וחיפוש פסיקה-חסרה
נוהל-יו"ר (2026-06-11): מבנה מספר-תיק = <סידורי>-<חודש>-<שנה>, ואורך הסידורי
מקודד את סוג-ההליך — 4 ספרות = ערר, 5 ספרות = בל"מ. הספרה הראשונה ממשיכה
לקבוע תחום בשני האורכים (1→רישוי, 8→היטל, 9→פיצויים). הכלל חד-כיווני:
5-ספרתי הוא תמיד בל"מ; 4-ספרתי אינו מחייב ערר (בל"מ-מורשת מזוהה מהנושא).

הבאג שדיווח עליו היו"ר: חיפוש פסיקה-חסרה לפי מספר-תיק החזיר 404 על כל ערך
שאינו תיק קיים — שבר את הטבלה תוך כדי הקלדה ועל מספרי 5-ספרות.

תיקונים:
- web/app.py: GET /api/missing-precedents — מסנן case_number שלא תאם תיק מחזיר
  רשימה ריקה (200), לא 404. סמנטיקה תקינה ל-collection-filter.
- missing-precedents/page.tsx: debounce (350ms) על שדות-הסינון — קוורי אחד
  אחרי שמפסיקים להקליד, לא אחד לכל הקשה.
- practice_area.py: regex סידורי \d{4}→\d{4,5}; case_serial_digits() +
  is_blam_by_number() (5⇒בל"מ); derive_subtype_with_blam ו-derive_proceeding_type
  מזהים בל"מ גם מ-5-ספרות (בנוסף לנושא). callers: cases.py, internal_decisions.py.
- proofreader.py: דפוסי חילוץ-שם-קובץ \d{3,4}→\d{3,5}.
- web-ui: practice-area.ts (מראָה ל-backend), schemas/case.ts (regex
  serial-month-year, 4-or-5 ספרות, superRefine 5⇒בל"מ), placeholder בוויזרד.
- תיעוד: docs/spec/X1-identifiers.md §1א + legal-ai/CLAUDE.md.

Invariants: מקיים G1 (נרמול-במקור — ספרה ראשונה כמקור-אמת יחיד לתחום),
G2 (מסלול-סיווג יחיד, אין כפילות), INV-DM/X1 (מפתח קנוני + proceeding_type).

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

400 lines
15 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 { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { PartiesField } from "@/components/wizard/parties-field";
import { useCreateCase } from "@/lib/api/cases";
import {
caseCreateSchema, expectedOutcomes, proceedingTypes,
type CaseCreateInput,
} from "@/lib/schemas/case";
import {
PRACTICE_AREAS, APPEAL_SUBTYPES, deriveSubtypeWithBlam,
type AppealSubtype,
} from "@/lib/practice-area";
const STEPS = [
{ key: "basics", label: "פרטי יסוד" },
{ key: "parties", label: "צדדים" },
{ key: "details", label: "השלמות" },
] as const;
type StepKey = (typeof STEPS)[number]["key"];
/* Fields validated at each step — lets the user fix just what's on screen
* before moving forward, instead of surfacing all errors from page 1. */
const STEP_FIELDS: Record<StepKey, (keyof CaseCreateInput)[]> = {
basics: ["case_number", "title", "proceeding_type", "practice_area", "appeal_subtype"],
parties: ["appellants", "respondents"],
details: ["subject", "hearing_date", "expected_outcome", "notes", "property_address", "permit_number"],
};
function FieldError({ message }: { message?: string }) {
if (!message) return null;
return <p className="text-[0.72rem] text-danger mt-1">{message}</p>;
}
export function CaseWizard() {
const router = useRouter();
const [step, setStep] = useState<StepKey>("basics");
const mutate = useCreateCase();
const form = useForm<CaseCreateInput>({
resolver: zodResolver(caseCreateSchema),
mode: "onBlur",
defaultValues: {
case_number: "",
title: "",
appellants: [],
respondents: [],
subject: "",
property_address: "",
permit_number: "",
hearing_date: "",
notes: "",
expected_outcome: "",
practice_area: "appeals_committee",
appeal_subtype: "unknown",
proceeding_type: "ערר",
},
});
/*
* Auto-fill appeal_subtype from the case number as the user types, but
* stop the moment they manually pick a value from the dropdown. Mirrors
* the wireSubtypeAutofill() behaviour of the vanilla UI
* (legal-ai/web/static/index.html around line 2770).
*
* proceeding_type follows the same pattern: if the user hasn't picked
* a value yet, we derive 'בל"מ' whenever the subtype lands on an
* extension_request_* value.
*/
const userTouchedSubtype = useRef(false);
const userTouchedProceeding = useRef(false);
const caseNumber = form.watch("case_number");
const practiceArea = form.watch("practice_area");
const subject = form.watch("subject");
const appealSubtype = form.watch("appeal_subtype");
useEffect(() => {
if (userTouchedSubtype.current) return;
/* derive_subtype_with_blam picks extension_request_* when subject
* matches "בקשה להארכת מועד" / "בל\"מ" / "הארכת מועד להגשת". */
const derived = deriveSubtypeWithBlam(caseNumber, subject, practiceArea);
if (derived !== form.getValues("appeal_subtype")) {
form.setValue("appeal_subtype", derived, { shouldValidate: false });
}
}, [caseNumber, practiceArea, subject, form]);
/* proceeding_type follows appeal_subtype when the user hasn't picked
* one explicitly — extension_request_* always implies 'בל"מ'. */
useEffect(() => {
if (userTouchedProceeding.current) return;
const proc = appealSubtype.startsWith("extension_request_") ? 'בל"מ' : "ערר";
if (proc !== form.getValues("proceeding_type")) {
form.setValue("proceeding_type", proc, { shouldValidate: false });
}
}, [appealSubtype, form]);
const stepIndex = STEPS.findIndex((s) => s.key === step);
const isLast = stepIndex === STEPS.length - 1;
const goNext = async () => {
const ok = await form.trigger(STEP_FIELDS[step]);
if (!ok) return;
setStep(STEPS[stepIndex + 1].key);
};
const goBack = () => setStep(STEPS[stepIndex - 1].key);
const onSubmit = form.handleSubmit(async (values) => {
try {
const res = await mutate.mutateAsync(values);
toast.success("תיק חדש נוצר");
const created = res?.case_number || values.case_number;
router.push(`/cases/${encodeURIComponent(created)}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה ביצירת תיק");
}
});
return (
<Card className="bg-surface border-rule shadow-sm max-w-3xl">
<CardContent className="px-6 py-6 space-y-6">
{/* Stepper */}
<ol className="flex items-center gap-2 text-sm">
{STEPS.map((s, i) => {
const active = i === stepIndex;
const done = i < stepIndex;
return (
<li key={s.key} className="flex items-center gap-2">
<span
className={`
inline-flex items-center justify-center w-7 h-7 rounded-full
font-display font-bold text-sm tabular-nums transition-colors
${done ? "bg-success text-parchment" : active ? "bg-navy text-parchment" : "bg-rule text-ink-muted"}
`}
>
{done ? "✓" : i + 1}
</span>
<span className={active ? "text-navy font-semibold" : "text-ink-muted"}>
{s.label}
</span>
{i < STEPS.length - 1 && (
<span className="w-8 h-px bg-rule mx-1" aria-hidden />
)}
</li>
);
})}
</ol>
<form onSubmit={onSubmit} className="space-y-5">
{step === "basics" && (
<div className="space-y-4">
<div>
<Label htmlFor="case_number" className="text-navy">
מספר תיק <span className="text-danger">*</span>
</Label>
<Input
id="case_number"
placeholder='1230-04-26 (ערר) · 85074-09-24 (בל"מ)'
{...form.register("case_number")}
className="mt-1 tabular-nums"
/>
<FieldError message={form.formState.errors.case_number?.message} />
</div>
<div>
<Label htmlFor="title" className="text-navy">
כותרת <span className="text-danger">*</span>
</Label>
<Input id="title" {...form.register("title")} className="mt-1" />
<FieldError message={form.formState.errors.title?.message} />
</div>
<div>
<Label className="text-navy">
סוג תיק <span className="text-danger">*</span>
</Label>
<Controller
control={form.control}
name="proceeding_type"
render={({ field }) => (
<Select
value={field.value}
onValueChange={(v) => {
userTouchedProceeding.current = true;
field.onChange(v as CaseCreateInput["proceeding_type"]);
}}
dir="rtl"
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{proceedingTypes.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<p className="text-[0.7rem] text-ink-muted mt-1">
ערר = הליך עיקרי; בל&quot;מ = בקשה להארכת מועד להגשת ערר
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-navy">תחום משפטי</Label>
<Controller
control={form.control}
name="practice_area"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange} dir="rtl">
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRACTICE_AREAS.map((p) => (
<SelectItem
key={p.value}
value={p.value}
disabled={!p.enabled}
>
{p.label}{!p.enabled && " (בקרוב)"}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div>
<Label className="text-navy">סוג ערר</Label>
<Controller
control={form.control}
name="appeal_subtype"
render={({ field }) => (
<Select
value={field.value}
onValueChange={(v) => {
userTouchedSubtype.current = true;
field.onChange(v as AppealSubtype);
}}
dir="rtl"
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{APPEAL_SUBTYPES.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<p className="text-[0.7rem] text-ink-muted mt-1">
מזוהה אוטומטית ממספר התיק
</p>
</div>
</div>
</div>
)}
{step === "parties" && (
<div className="space-y-5">
<Controller
control={form.control}
name="appellants"
render={({ field, fieldState }) => (
<PartiesField
label="עוררים *"
value={field.value}
onChange={field.onChange}
error={fieldState.error?.message}
/>
)}
/>
<div className="h-px bg-rule" />
<Controller
control={form.control}
name="respondents"
render={({ field, fieldState }) => (
<PartiesField
label="משיבים *"
value={field.value}
onChange={field.onChange}
error={fieldState.error?.message}
/>
)}
/>
</div>
)}
{step === "details" && (
<div className="space-y-4">
<div>
<Label htmlFor="subject" className="text-navy">נושא</Label>
<Input id="subject" {...form.register("subject")} className="mt-1" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="property_address" className="text-navy">כתובת הנכס</Label>
<Input id="property_address" {...form.register("property_address")} className="mt-1" />
</div>
<div>
<Label htmlFor="permit_number" className="text-navy">מס׳ תכנית/בקשה</Label>
<Input id="permit_number" {...form.register("permit_number")} className="mt-1" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="hearing_date" className="text-navy">תאריך דיון</Label>
<Input
id="hearing_date"
type="date"
{...form.register("hearing_date")}
className="mt-1 tabular-nums"
/>
<FieldError message={form.formState.errors.hearing_date?.message} />
</div>
<div>
<Label className="text-navy">תוצאה צפויה</Label>
<Controller
control={form.control}
name="expected_outcome"
render={({ field }) => (
<Select
value={field.value || "__none__"}
onValueChange={(v) => field.onChange(v === "__none__" ? "" : v)}
dir="rtl"
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{expectedOutcomes.map((o) => (
<SelectItem key={o.value || "none"} value={o.value || "__none__"}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
</div>
<div>
<Label htmlFor="notes" className="text-navy">הערות</Label>
<Textarea id="notes" rows={4} {...form.register("notes")} className="mt-1" />
</div>
</div>
)}
<div className="flex items-center justify-between gap-3 pt-2">
<Button
type="button"
variant="ghost"
onClick={goBack}
disabled={stepIndex === 0 || mutate.isPending}
>
הקודם
</Button>
{isLast ? (
<Button
key="submit-btn"
type="submit"
disabled={mutate.isPending}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{mutate.isPending ? "יוצר תיק…" : "צור תיק"}
</Button>
) : (
<Button
key="next-btn"
type="button"
onClick={goNext}
className="bg-navy hover:bg-navy-soft text-parchment"
>
הבא
</Button>
)}
</div>
</form>
</CardContent>
</Card>
);
}