Files
legal-ai/web-ui/src/components/wizard/case-wizard.tsx
Chaim 83b6ff51b7
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m38s
feat: fix wizard step-skip bug + extend case edit with all fields + Paperclip title sync
- 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>
2026-05-17 10:55:45 +00:00

347 lines
13 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,
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>
);
}