Phase 4b: Case create wizard
New /cases/new route with a 3-step wizard (basics / parties / details) backed by react-hook-form + the caseCreateSchema. Each step validates only its own fields so the user fixes errors in context. On success useCreateCase invalidates the case list and the router pushes to the freshly created case detail page. PartiesField is a small chip-style editor for the appellants/respondents arrays. The Home page now has a navy "+ תיק חדש" button that links to the wizard. Dropped .default() from the create schema — zod's input/output type mismatch broke the RHF zodResolver generics; dropping the defaults is simpler than plumbing z.input vs z.output through the mutation hook. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
283
web-ui/src/components/wizard/case-wizard.tsx
Normal file
283
web-ui/src/components/wizard/case-wizard.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import { 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, committeeTypes, expectedOutcomes,
|
||||
type CaseCreateInput,
|
||||
} from "@/lib/schemas/case";
|
||||
|
||||
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", "committee_type"],
|
||||
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: "",
|
||||
committee_type: "ועדה מקומית",
|
||||
hearing_date: "",
|
||||
notes: "",
|
||||
expected_outcome: "",
|
||||
},
|
||||
});
|
||||
|
||||
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="1234 או 8001/2026"
|
||||
{...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">סוג ועדה</Label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="committee_type"
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange} dir="rtl">
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{committeeTypes.map((t) => (
|
||||
<SelectItem key={t} value={t}>{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</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
|
||||
type="submit"
|
||||
disabled={mutate.isPending}
|
||||
className="bg-navy hover:bg-navy-soft text-parchment"
|
||||
>
|
||||
{mutate.isPending ? "יוצר תיק…" : "צור תיק"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
className="bg-navy hover:bg-navy-soft text-parchment"
|
||||
>
|
||||
הבא →
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user