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

@@ -8,8 +8,9 @@
* surfaces as a runtime TypeScript error the first time a property is touched.
*/
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "./client";
import type { CaseCreateInput, CaseUpdateInput } from "@/lib/schemas/case";
export type CaseStatus =
| "new"
@@ -95,6 +96,41 @@ export type WorkflowStatus = {
[key: string]: unknown;
};
export function useCreateCase() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CaseCreateInput) =>
apiRequest<{ case_number?: string; message?: string; [k: string]: unknown }>(
`/api/cases/create`,
{ method: "POST", body: input },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: casesKeys.all });
},
});
}
export function useUpdateCase(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CaseUpdateInput) =>
apiRequest<CaseDetail>(`/api/cases/${caseNumber}`, {
method: "PUT",
body: input,
}),
onSuccess: (data) => {
/* Patch cached detail and nudge the list to refetch on next focus */
if (caseNumber) {
qc.setQueryData<CaseDetail | undefined>(
casesKeys.detail(caseNumber),
(prev) => (prev ? { ...prev, ...data } : prev),
);
}
qc.invalidateQueries({ queryKey: casesKeys.all });
},
});
}
export function useWorkflowStatus(caseNumber: string | undefined) {
return useQuery({
queryKey: [...casesKeys.all, "workflow", caseNumber ?? ""] as const,

View File

@@ -2,6 +2,7 @@
import { useState, type ReactNode } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/sonner";
import { makeQueryClient } from "@/lib/api/client";
/**
@@ -12,6 +13,9 @@ import { makeQueryClient } from "@/lib/api/client";
export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(() => makeQueryClient());
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<QueryClientProvider client={queryClient}>
{children}
<Toaster position="top-left" richColors closeButton dir="rtl" />
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,82 @@
/**
* Zod schemas for case mutations (create / update).
* Shapes mirror the FastAPI Pydantic models in web/app.py:
* - CaseCreateRequest
* - CaseUpdateRequest
*
* Validation rules are stricter on the UI than on the backend so the user
* gets Hebrew-localized error messages at the field level instead of a
* 422 blob from FastAPI.
*/
import { z } from "zod";
/* Appeal numbers follow the 1xxx / 8xxx / 9xxx convention from CLAUDE.md —
* permissive regex that still catches obvious typos. */
const caseNumberRe = /^[1-9]\d{3,}(?:[-/][\w\u0590-\u05FF]+)*$/;
const hebrewPartyRe = /[\u0590-\u05FFA-Za-z]/;
const dateString = z
.string()
.trim()
.refine((v) => v === "" || /^\d{4}-\d{2}-\d{2}$/.test(v), {
message: "תאריך חייב להיות בפורמט YYYY-MM-DD",
});
export const committeeTypes = [
"ועדה מקומית",
"ועדה מחוזית",
"ועדת ערר",
] as const;
export const expectedOutcomes = [
{ value: "", label: "— לא נקבע —" },
{ value: "rejection", label: "דחייה" },
{ value: "partial_acceptance", label: "קבלה חלקית" },
{ value: "full_acceptance", label: "קבלה מלאה" },
{ value: "betterment_levy", label: "היטל השבחה" },
] as const;
export const caseCreateSchema = z.object({
case_number: z
.string()
.trim()
.min(1, "שדה חובה")
.regex(caseNumberRe, "מספר תיק לא תקין (למשל 1234 או 8001/2026)"),
title: z.string().trim().min(3, "כותרת קצרה מדי").max(200, "כותרת ארוכה מדי"),
appellants: z
.array(z.string().trim().min(1).refine((v) => hebrewPartyRe.test(v), "שם לא תקין"))
.min(1, "חייב להיות לפחות עורר אחד"),
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(""),
});
export type CaseCreateInput = z.infer<typeof caseCreateSchema>;
/* Update schema — all fields optional so the PUT can be used for a
* single-field edit. Empty strings are tolerated (they mean "no change"
* in the Pydantic model). */
export const caseUpdateSchema = z.object({
title: z.string().trim().max(200).optional(),
subject: z.string().trim().max(500).optional(),
notes: z.string().trim().max(2000).optional(),
hearing_date: dateString.optional(),
decision_date: dateString.optional(),
expected_outcome: z
.enum(expectedOutcomes.map((o) => o.value) as [string, ...string[]])
.optional(),
status: z.string().optional(),
});
export type CaseUpdateInput = z.infer<typeof caseUpdateSchema>;