Phase 4a: shadcn form primitives + case inline edit

Add dialog/select/textarea/label/progress/sonner components and wire
a Toaster into Providers. New zod schemas in lib/schemas/case.ts
mirror CaseCreateRequest/CaseUpdateRequest and feed react-hook-form
validation.

CaseEditDialog on the case detail Actions tab posts PUT /api/cases/{n}
with optimistic cache patching via useUpdateCase, showing toast
feedback on success/error.

shadcn's <Form> registry entry skipped at init (missing from the
nova preset); the dialog uses RHF directly against the same Input/
Textarea/Select primitives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 16:21:21 +00:00
parent 03b25bc273
commit 9fcf4f2dc7
14 changed files with 808 additions and 15 deletions

View File

@@ -0,0 +1,160 @@
"use client";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import {
Dialog, DialogContent, DialogDescription, DialogFooter,
DialogHeader, DialogTitle, DialogTrigger,
} from "@/components/ui/dialog";
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 { useUpdateCase } from "@/lib/api/cases";
import { caseUpdateSchema, expectedOutcomes, type CaseUpdateInput } from "@/lib/schemas/case";
import type { CaseDetail } from "@/lib/api/cases";
/*
* Inline edit dialog for core case fields. Uses react-hook-form + zod
* directly (shadcn's <Form> registry entry wasn't available at init
* time, so the styling is reproduced by hand in a lean form layout).
*/
function FieldError({ message }: { message?: string }) {
if (!message) return null;
return <p className="text-[0.72rem] text-danger mt-1">{message}</p>;
}
export function CaseEditDialog({ data }: { data: CaseDetail }) {
const [open, setOpen] = useState(false);
const mutate = useUpdateCase(data.case_number);
const form = useForm<CaseUpdateInput>({
resolver: zodResolver(caseUpdateSchema),
defaultValues: {
title: data.title ?? "",
subject: data.subject ?? "",
hearing_date: data.hearing_date ?? "",
notes: "",
expected_outcome: data.expected_outcome ?? "",
},
});
/* Re-sync the form when the underlying case refetches after save */
useEffect(() => {
if (!open) return;
form.reset({
title: data.title ?? "",
subject: data.subject ?? "",
hearing_date: data.hearing_date ?? "",
notes: "",
expected_outcome: data.expected_outcome ?? "",
});
}, [open, data, form]);
const onSubmit = form.handleSubmit(async (values) => {
try {
await mutate.mutateAsync(values);
toast.success("פרטי התיק עודכנו");
setOpen(false);
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה בעדכון התיק");
}
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
עריכת פרטי תיק
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg" dir="rtl">
<DialogHeader>
<DialogTitle>עריכת פרטי תיק {data.case_number}</DialogTitle>
<DialogDescription className="text-ink-muted">
השינויים נשמרים ישירות ל-FastAPI. השדות הריקים נשארים ללא שינוי.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-4">
<div>
<Label htmlFor="title" className="text-navy">כותרת</Label>
<Input id="title" {...form.register("title")} className="mt-1" />
<FieldError message={form.formState.errors.title?.message} />
</div>
<div>
<Label htmlFor="subject" className="text-navy">נושא</Label>
<Input id="subject" {...form.register("subject")} className="mt-1" />
<FieldError message={form.formState.errors.subject?.message} />
</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>
<Select
value={form.watch("expected_outcome") || "__none__"}
onValueChange={(v) =>
form.setValue("expected_outcome", 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={3} {...form.register("notes")} className="mt-1" />
<FieldError message={form.formState.errors.notes?.message} />
</div>
<DialogFooter className="gap-2">
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
disabled={mutate.isPending}
>
ביטול
</Button>
<Button
type="submit"
disabled={mutate.isPending}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{mutate.isPending ? "שומר…" : "שמור שינויים"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}