diff --git a/web-ui/src/app/cases/new/page.tsx b/web-ui/src/app/cases/new/page.tsx
new file mode 100644
index 0000000..3fa0d32
--- /dev/null
+++ b/web-ui/src/app/cases/new/page.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/web-ui/src/app/page.tsx b/web-ui/src/app/page.tsx
index a8e3992..607232b 100644
--- a/web-ui/src/app/page.tsx
+++ b/web-ui/src/app/page.tsx
@@ -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 הבלוקים.
+
diff --git a/web-ui/src/components/wizard/case-wizard.tsx b/web-ui/src/components/wizard/case-wizard.tsx
new file mode 100644
index 0000000..30b89fe
--- /dev/null
+++ b/web-ui/src/components/wizard/case-wizard.tsx
@@ -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 = {
+ 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 {message}
;
+}
+
+export function CaseWizard() {
+ const router = useRouter();
+ const [step, setStep] = useState("basics");
+ const mutate = useCreateCase();
+
+ const form = useForm({
+ 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 (
+
+
+ {/* Stepper */}
+
+ {STEPS.map((s, i) => {
+ const active = i === stepIndex;
+ const done = i < stepIndex;
+ return (
+ -
+
+ {done ? "✓" : i + 1}
+
+
+ {s.label}
+
+ {i < STEPS.length - 1 && (
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+ );
+}
diff --git a/web-ui/src/components/wizard/parties-field.tsx b/web-ui/src/components/wizard/parties-field.tsx
new file mode 100644
index 0000000..6b716f1
--- /dev/null
+++ b/web-ui/src/components/wizard/parties-field.tsx
@@ -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 (
+
+
+ {value.length > 0 && (
+
+ {value.map((name) => (
+ -
+ {name}
+
+
+ ))}
+
+ )}
+
+
setDraft(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ add();
+ }
+ }}
+ placeholder="שם מלא של הצד"
+ dir="rtl"
+ />
+
+
+ {error &&
{error}
}
+
+ );
+}
diff --git a/web-ui/src/lib/schemas/case.ts b/web-ui/src/lib/schemas/case.ts
index 96e308b..022342c 100644
--- a/web-ui/src/lib/schemas/case.ts
+++ b/web-ui/src/lib/schemas/case.ts
@@ -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;