Persist values, tighten validation, and a11y on /onboarding
- Server action echoes kitaName, the three module booleans, and the
Mindest-Verfügbarkeiten value on every error path so failed submits no
longer wipe what the user typed; OnboardingState gains an `attempt`
counter that drives a per-field remount key on the client.
- Schema now rejects min=0 when the Notdienst module is active (via
superRefine) — previously a kita could be created with 0 minimum days,
which is fachlich sinnlos. When the module is off, 0 stays valid.
- Validation error messages reworded from terse fragments ("Nicht
negativ.", "Bitte ganze Zahl.") to full sentences.
- Field error <p> elements gain role="alert" + aria-live="polite" so
screen readers announce them on submit.
- CardTitle now accepts as="h1" and the onboarding page uses it for
"Willkommen, …" — the page now has a real top-level heading.
- ModuleCheckbox uses aria-labelledby + aria-describedby so the
accessible name is just the module title; the longer description is
exposed as secondary info instead of being concatenated into the AN.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,21 +16,35 @@ import { prisma } from "@/lib/prisma";
|
||||
// — kein "halb-eingerichteter" Zwischenzustand.
|
||||
// =====================================================================
|
||||
|
||||
const onboardingSchema = z.object({
|
||||
kitaName: z
|
||||
.string()
|
||||
.min(2, "Mindestens 2 Zeichen.")
|
||||
.max(120, "Maximal 120 Zeichen.")
|
||||
.trim(),
|
||||
notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
|
||||
terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
|
||||
adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
|
||||
notdienstMinPerChildPerMonth: z.coerce
|
||||
.number()
|
||||
.int("Bitte ganze Zahl.")
|
||||
.min(0, "Nicht negativ.")
|
||||
.max(31, "Maximal 31 Tage pro Monat."),
|
||||
});
|
||||
const onboardingSchema = z
|
||||
.object({
|
||||
kitaName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, "Bitte mindestens 2 Zeichen angeben.")
|
||||
.max(120, "Maximal 120 Zeichen."),
|
||||
notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
|
||||
terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
|
||||
adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
|
||||
notdienstMinPerChildPerMonth: z.coerce
|
||||
.number()
|
||||
.int("Bitte eine ganze Zahl angeben.")
|
||||
.min(0, "Bitte einen Wert von 0 oder mehr angeben.")
|
||||
.max(31, "Maximal 31 Tage pro Monat."),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
// Mindest-Verfügbarkeit nur relevant, wenn das Notdienst-Modul aktiv ist.
|
||||
// Bei aktivem Modul muss mindestens 1 Tag eingetragen sein — 0 wäre
|
||||
// fachlich sinnlos und würde den Planer leerlaufen lassen.
|
||||
if (data.notdienstModuleEnabled && data.notdienstMinPerChildPerMonth < 1) {
|
||||
ctx.addIssue({
|
||||
path: ["notdienstMinPerChildPerMonth"],
|
||||
code: "custom",
|
||||
message:
|
||||
"Bei aktivem Notdienst-Modul: mindestens 1 Tag pro Monat angeben.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function checkboxToBool(value: unknown): boolean {
|
||||
// HTML-Checkbox: "on" wenn aktiviert, sonst nicht im FormData.
|
||||
@@ -38,6 +52,17 @@ function checkboxToBool(value: unknown): boolean {
|
||||
}
|
||||
|
||||
export type OnboardingState = {
|
||||
// Bumped after every action call so the client can key uncontrolled inputs
|
||||
// and force a remount with the echoed values from a failed submit.
|
||||
attempt: number;
|
||||
// Echoed inputs so a failed submit doesn't wipe the form.
|
||||
values?: {
|
||||
kitaName?: string;
|
||||
notdienstModuleEnabled?: boolean;
|
||||
terminModuleEnabled?: boolean;
|
||||
adressbuchModuleEnabled?: boolean;
|
||||
notdienstMinPerChildPerMonth?: number;
|
||||
};
|
||||
errors?: {
|
||||
kitaName?: string[];
|
||||
notdienstMinPerChildPerMonth?: string[];
|
||||
@@ -45,10 +70,25 @@ export type OnboardingState = {
|
||||
};
|
||||
};
|
||||
|
||||
export const initialOnboardingState: OnboardingState = { attempt: 0 };
|
||||
|
||||
function echoValues(formData: FormData): OnboardingState["values"] {
|
||||
const min = formData.get("notdienstMinPerChildPerMonth");
|
||||
return {
|
||||
kitaName: String(formData.get("kitaName") ?? ""),
|
||||
notdienstModuleEnabled: formData.get("notdienstModuleEnabled") === "on",
|
||||
terminModuleEnabled: formData.get("terminModuleEnabled") === "on",
|
||||
adressbuchModuleEnabled: formData.get("adressbuchModuleEnabled") === "on",
|
||||
notdienstMinPerChildPerMonth:
|
||||
min === null || min === "" ? undefined : Number(min),
|
||||
};
|
||||
}
|
||||
|
||||
export async function completeOnboardingAction(
|
||||
_prev: OnboardingState,
|
||||
prev: OnboardingState,
|
||||
formData: FormData,
|
||||
): Promise<OnboardingState> {
|
||||
const attempt = prev.attempt + 1;
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
redirect("/login");
|
||||
@@ -61,7 +101,11 @@ export async function completeOnboardingAction(
|
||||
|
||||
const parsed = onboardingSchema.safeParse(Object.fromEntries(formData));
|
||||
if (!parsed.success) {
|
||||
return { errors: parsed.error.flatten().fieldErrors };
|
||||
return {
|
||||
attempt,
|
||||
values: echoValues(formData),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
const data = parsed.data;
|
||||
|
||||
@@ -99,6 +143,8 @@ export async function completeOnboardingAction(
|
||||
}
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return {
|
||||
attempt,
|
||||
values: echoValues(formData),
|
||||
errors: { _form: ["Datenbankfehler — bitte erneut versuchen."] },
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user