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:
2026-04-11 16:23:37 +00:00
parent 9fcf4f2dc7
commit b67dc47dc7
5 changed files with 412 additions and 9 deletions

View File

@@ -0,0 +1,26 @@
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
import { CaseWizard } from "@/components/wizard/case-wizard";
export default function NewCasePage() {
return (
<AppShell>
<section className="space-y-6">
<header>
<nav className="text-[0.78rem] text-ink-muted flex items-center gap-2 mb-1">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden>·</span>
<span className="text-navy">תיק חדש</span>
</nav>
<h1 className="text-navy mb-0">יצירת תיק ערר</h1>
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
שלושה שלבים קצרים פרטי יסוד, צדדים, והשלמות. התיק ייווצר ב-DB
וב-Gitea מיד בשמירה.
</p>
</header>
<CaseWizard />
</section>
</AppShell>
);
}

View File

@@ -1,10 +1,12 @@
"use client";
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
import { KPICards } from "@/components/cases/kpi-cards";
import { StatusDonut } from "@/components/cases/status-donut";
import { CasesTable } from "@/components/cases/cases-table";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useCases } from "@/lib/api/cases";
export default function HomePage() {
@@ -24,6 +26,9 @@ export default function HomePage() {
12 הבלוקים.
</p>
</div>
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
<Link href="/cases/new">+ תיק חדש</Link>
</Button>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />

View 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>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import { useState } from "react";
import { X, Plus } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
/*
* Minimal tag-style editor for a list of party names (appellants / respondents).
* Backed by a controlled string[] — submits as the same shape the FastAPI
* CaseCreateRequest expects. Enter adds the current draft; X removes a chip.
*/
export function PartiesField({
label,
value,
onChange,
error,
}: {
label: string;
value: string[];
onChange: (next: string[]) => void;
error?: string;
}) {
const [draft, setDraft] = useState("");
const add = () => {
const trimmed = draft.trim();
if (!trimmed) return;
if (value.includes(trimmed)) {
setDraft("");
return;
}
onChange([...value, trimmed]);
setDraft("");
};
const remove = (name: string) => {
onChange(value.filter((v) => v !== name));
};
return (
<div>
<label className="block text-sm font-medium text-navy mb-1.5">{label}</label>
{value.length > 0 && (
<ul className="flex flex-wrap gap-2 mb-2">
{value.map((name) => (
<li
key={name}
className="
inline-flex items-center gap-1.5 rounded-full
bg-gold-wash text-gold-deep border border-gold/40
px-3 py-1 text-sm
"
>
<span>{name}</span>
<button
type="button"
onClick={() => remove(name)}
className="hover:text-danger transition-colors"
aria-label={`הסר ${name}`}
>
<X className="w-3.5 h-3.5" />
</button>
</li>
))}
</ul>
)}
<div className="flex gap-2">
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
add();
}
}}
placeholder="שם מלא של הצד"
dir="rtl"
/>
<Button type="button" variant="outline" size="sm" onClick={add}>
<Plus className="w-4 h-4" />
</Button>
</div>
{error && <p className="text-[0.72rem] text-danger mt-1">{error}</p>}
</div>
);
}

View File

@@ -51,15 +51,15 @@ export const caseCreateSchema = z.object({
respondents: z
.array(z.string().trim().min(1).refine((v) => hebrewPartyRe.test(v), "שם לא תקין"))
.min(1, "חייב להיות לפחות משיב אחד"),
subject: z.string().trim().max(500).default(""),
property_address: z.string().trim().max(200).default(""),
permit_number: z.string().trim().max(100).default(""),
committee_type: z.enum(committeeTypes).default("ועדה מקומית"),
hearing_date: dateString.default(""),
notes: z.string().trim().max(2000).default(""),
expected_outcome: z
.enum(expectedOutcomes.map((o) => o.value) as [string, ...string[]])
.default(""),
subject: z.string().trim().max(500),
property_address: z.string().trim().max(200),
permit_number: z.string().trim().max(100),
committee_type: z.enum(committeeTypes),
hearing_date: dateString,
notes: z.string().trim().max(2000),
expected_outcome: z.enum(
expectedOutcomes.map((o) => o.value) as [string, ...string[]],
),
});
export type CaseCreateInput = z.infer<typeof caseCreateSchema>;