All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m38s
- Fix keyboard navigation bug: React was reusing the submit button DOM element when transitioning "הבא" → "צור תיק", retaining focus and causing Enter to auto-submit step 3. Added key props to force element replacement. - CaseEditDialog now covers all wizard fields: appellants, respondents, property_address, permit_number (in addition to existing title, subject, hearing_date, expected_outcome, notes). - When case title changes, Paperclip project name is updated in background via new update_project_name() in paperclip_client.py. - Extended CaseUpdateRequest, case_update MCP tool, and caseUpdateSchema to carry the new fields end-to-end. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
347 lines
13 KiB
TypeScript
347 lines
13 KiB
TypeScript
"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,
|
||
type CaseCreateInput,
|
||
} from "@/lib/schemas/case";
|
||
import {
|
||
PRACTICE_AREAS, APPEAL_SUBTYPES, deriveSubtype,
|
||
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", "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",
|
||
},
|
||
});
|
||
|
||
/*
|
||
* 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).
|
||
*/
|
||
const userTouchedSubtype = useRef(false);
|
||
const caseNumber = form.watch("case_number");
|
||
const practiceArea = form.watch("practice_area");
|
||
useEffect(() => {
|
||
if (userTouchedSubtype.current) return;
|
||
const derived = deriveSubtype(caseNumber, practiceArea);
|
||
if (derived !== form.getValues("appeal_subtype")) {
|
||
form.setValue("appeal_subtype", derived, { shouldValidate: false });
|
||
}
|
||
}, [caseNumber, practiceArea, 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="1033-25 או 1000-04-26"
|
||
{...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 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>
|
||
);
|
||
}
|