Fix live test follow-up issues

This commit is contained in:
t.indorf
2026-05-19 14:28:32 +02:00
parent 3fa3b9b108
commit 99ff978116
9 changed files with 79 additions and 24 deletions
+2
View File
@@ -12,6 +12,8 @@ import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-an
import { TerminList, type CalendarListItemDto } from "./_components/termin-list"; import { TerminList, type CalendarListItemDto } from "./_components/termin-list";
import { TerminRequestModal } from "./_components/termin-request-modal"; import { TerminRequestModal } from "./_components/termin-request-modal";
export const metadata = { title: "Terminkalender · Kita-Planer" };
export default async function KalenderPage({ export default async function KalenderPage({
searchParams, searchParams,
}: { }: {
+27 -8
View File
@@ -15,11 +15,23 @@ import { getTargetMonthData } from "@/lib/date-utils";
// Die Termine werden Round-Robin auf die Kinder aufgeteilt. // Die Termine werden Round-Robin auf die Kinder aufgeteilt.
// ===================================================================== // =====================================================================
const dateKeyPattern = /^\d{4}-\d{2}-\d{2}$/;
const payloadSchema = z.object({ const payloadSchema = z.object({
childrenIds: z.array(z.string()).min(1), childrenIds: z.array(z.string()).min(1),
dates: z.array(z.string().datetime()), // ISO strings dates: z.array(
z.string().regex(dateKeyPattern).refine((value) => {
const date = dateKeyToUtcDate(value);
return value === date.toISOString().slice(0, 10);
}),
),
}); });
function dateKeyToUtcDate(dateKey: string) {
const [year, month, day] = dateKey.split("-").map(Number);
return new Date(Date.UTC(year, month - 1, day));
}
export async function saveNotdienstAvailabilities(payloadRaw: { export async function saveNotdienstAvailabilities(payloadRaw: {
childrenIds: string[]; childrenIds: string[];
dates: string[]; dates: string[];
@@ -35,7 +47,8 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
return { error: "Ungültige Eingabedaten." }; return { error: "Ungültige Eingabedaten." };
} }
const { childrenIds, dates } = parsed.data; const { childrenIds } = parsed.data;
const dates = Array.from(new Set(parsed.data.dates)).sort();
// ── 1. Sperrfrist prüfen ─────────────────────────────────────────── // ── 1. Sperrfrist prüfen ───────────────────────────────────────────
const { targetYear, targetMonth, isLocked } = getTargetMonthData(); const { targetYear, targetMonth, isLocked } = getTargetMonthData();
@@ -79,9 +92,15 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
} }
// Termine in Date-Objekte wandeln und sortieren // Termine in Date-Objekte wandeln und sortieren
const parsedDates = dates const parsedDates = dates.map(dateKeyToUtcDate);
.map((d) => new Date(d)) const hasOutsideTargetMonth = parsedDates.some(
.sort((a, b) => a.getTime() - b.getTime()); (date) =>
date.getUTCFullYear() !== targetYear ||
date.getUTCMonth() !== targetMonth - 1,
);
if (hasOutsideTargetMonth) {
return { error: "Bitte wähle Termine im Zielmonat aus." };
}
// Round-Robin Zuweisung // Round-Robin Zuweisung
const inserts: { const inserts: {
@@ -110,8 +129,8 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
kitaId, kitaId,
childId: { in: childrenIds }, childId: { in: childrenIds },
date: { date: {
gte: new Date(targetYear, targetMonth - 1, 1), gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
lt: new Date(targetYear, targetMonth, 1), lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
}, },
}, },
}); });
@@ -126,7 +145,7 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
}); });
revalidatePath("/dashboard/notdienst"); revalidatePath("/dashboard/notdienst");
return { success: true }; return { success: true, dates };
} catch (error) { } catch (error) {
console.error("Fehler beim Speichern der Notdienste:", error); console.error("Fehler beim Speichern der Notdienste:", error);
return { error: "Ein Fehler ist aufgetreten beim Speichern der Termine." }; return { error: "Ein Fehler ist aufgetreten beim Speichern der Termine." };
@@ -1,13 +1,14 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Info, Loader2, CheckCircle2 } from "lucide-react"; import { Info, Loader2, CheckCircle2 } from "lucide-react";
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getWorkingDaysOfMonth } from "@/lib/date-utils"; import { formatDateKey, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { saveNotdienstAvailabilities } from "./actions"; import { saveNotdienstAvailabilities } from "./actions";
@@ -23,7 +24,7 @@ interface NotdienstFormProps {
targetMonth: number; targetMonth: number;
isLocked: boolean; isLocked: boolean;
requiredDaysTotal: number; requiredDaysTotal: number;
initialSelectedDates: string[]; // ISO Strings initialSelectedDates: string[];
childrenIds: string[]; childrenIds: string[];
} }
@@ -35,7 +36,8 @@ export function NotdienstForm({
initialSelectedDates, initialSelectedDates,
childrenIds, childrenIds,
}: NotdienstFormProps) { }: NotdienstFormProps) {
// Lokaler State für die Auswahl. Ein Datum liegt immer als ISO-String vor (z.B. "2026-06-01T00:00:00.000Z") const router = useRouter();
// Lokaler State für die Auswahl. Ein Datum liegt als stabiler yyyy-MM-dd-Schlüssel vor.
const [selectedDates, setSelectedDates] = useState<Set<string>>( const [selectedDates, setSelectedDates] = useState<Set<string>>(
new Set(initialSelectedDates), new Set(initialSelectedDates),
); );
@@ -78,7 +80,9 @@ export function NotdienstForm({
if (result?.error) { if (result?.error) {
toast.error(result.error); toast.error(result.error);
} else { } else {
setSelectedDates(new Set(result.dates));
toast.success("Verfügbarkeiten erfolgreich gespeichert."); toast.success("Verfügbarkeiten erfolgreich gespeichert.");
router.refresh();
} }
}); });
} }
@@ -134,7 +138,7 @@ export function NotdienstForm({
</div> </div>
<div className="divide-y"> <div className="divide-y">
{workingDays.map((day) => { {workingDays.map((day) => {
const dateStr = day.toISOString(); const dateStr = formatDateKey(day);
const isSelected = selectedDates.has(dateStr); const isSelected = selectedDates.has(dateStr);
const isToday = day.getTime() === new Date().setHours(0, 0, 0, 0); const isToday = day.getTime() === new Date().setHours(0, 0, 0, 0);
+6 -4
View File
@@ -4,7 +4,7 @@ import { Info, Calendar, ArrowRight, ShieldAlert } from "lucide-react";
import { requireRole } from "@/lib/auth-utils"; import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getTargetMonthData } from "@/lib/date-utils"; import { formatDateKey, getTargetMonthData } from "@/lib/date-utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { NotdienstEntry } from "./notdienst-entry"; import { NotdienstEntry } from "./notdienst-entry";
@@ -45,6 +45,8 @@ export default async function NotdienstPage() {
const targetData = getTargetMonthData(); const targetData = getTargetMonthData();
const { targetYear, targetMonth, monthName, isLocked } = targetData; const { targetYear, targetMonth, monthName, isLocked } = targetData;
const targetMonthStart = new Date(Date.UTC(targetYear, targetMonth - 1, 1));
const targetMonthEnd = new Date(Date.UTC(targetYear, targetMonth, 1));
const requiredDaysTotal = children.length * kita.notdienstMinPerChildPerMonth; const requiredDaysTotal = children.length * kita.notdienstMinPerChildPerMonth;
const childIds = children.map((child) => child.id); const childIds = children.map((child) => child.id);
@@ -58,8 +60,8 @@ export default async function NotdienstPage() {
kitaId, kitaId,
childId: { in: childIds }, childId: { in: childIds },
date: { date: {
gte: new Date(targetYear, targetMonth - 1, 1), gte: targetMonthStart,
lt: new Date(targetYear, targetMonth, 1), lt: targetMonthEnd,
}, },
}, },
select: { date: true }, select: { date: true },
@@ -77,7 +79,7 @@ export default async function NotdienstPage() {
]) ])
: [[], 0]; : [[], 0];
const selectedDates = availabilities.map((a) => a.date.toISOString()); const selectedDates = availabilities.map((a) => formatDateKey(a.date));
return ( return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-8"> <div className="mx-auto max-w-4xl px-4 py-8 sm:px-8">
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useTransition } from "react"; import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Save } from "lucide-react"; import { Loader2, Save } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -19,6 +20,7 @@ type ContactFormProps = {
}; };
export function ContactForm({ contact }: ContactFormProps) { export function ContactForm({ contact }: ContactFormProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
function handleSubmit(formData: FormData) { function handleSubmit(formData: FormData) {
@@ -36,6 +38,7 @@ export function ContactForm({ contact }: ContactFormProps) {
} }
toast.success("Kontaktdaten gespeichert."); toast.success("Kontaktdaten gespeichert.");
router.refresh();
}); });
} }
+9 -5
View File
@@ -18,6 +18,7 @@ import { signIn } from "@/auth";
const loginSchema = z.object({ const loginSchema = z.object({
email: z.string().email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase().trim(), email: z.string().email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase().trim(),
password: z.string().min(1, "Passwort fehlt."), password: z.string().min(1, "Passwort fehlt."),
callbackUrl: z.string().optional(),
}); });
export type LoginState = { export type LoginState = {
@@ -28,6 +29,12 @@ export type LoginState = {
}; };
}; };
function safeCallbackUrl(value: string | undefined) {
if (!value) return "/dashboard";
if (!value.startsWith("/") || value.startsWith("//")) return "/dashboard";
return value;
}
export async function loginAction( export async function loginAction(
_prev: LoginState, _prev: LoginState,
formData: FormData, formData: FormData,
@@ -37,16 +44,13 @@ export async function loginAction(
return { errors: parsed.error.flatten().fieldErrors }; return { errors: parsed.error.flatten().fieldErrors };
} }
const { email, password } = parsed.data; const { email, password } = parsed.data;
const redirectTo = safeCallbackUrl(parsed.data.callbackUrl);
try { try {
await signIn("credentials", { await signIn("credentials", {
email, email,
password, password,
// Auth-Utils entscheiden im Folge-Render, ob /onboarding oder /dashboard. redirectTo,
// Wir landen aus Sicherheitsgründen erstmal generisch auf der Wurzel.
// Der Proxy (proxy.ts) übernimmt das Routing:
// → /onboarding wenn kein kitaId, → /dashboard wenn vorhanden.
redirectTo: "/",
}); });
} catch (err) { } catch (err) {
// signIn wirft NEXT_REDIRECT bei Erfolg → das müssen wir bewusst // signIn wirft NEXT_REDIRECT bei Erfolg → das müssen wir bewusst
+3 -1
View File
@@ -10,11 +10,13 @@ import { loginAction, type LoginState } from "./actions";
const initialState: LoginState = {}; const initialState: LoginState = {};
export function LoginForm() { export function LoginForm({ callbackUrl }: { callbackUrl: string }) {
const [state, formAction, pending] = useActionState(loginAction, initialState); const [state, formAction, pending] = useActionState(loginAction, initialState);
return ( return (
<form action={formAction} className="space-y-4"> <form action={formAction} className="space-y-4">
<input type="hidden" name="callbackUrl" value={callbackUrl} />
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="email">E-Mail</Label> <Label htmlFor="email">E-Mail</Label>
<Input <Input
+17 -2
View File
@@ -15,11 +15,26 @@ import { LoginForm } from "./login-form";
export const metadata = { title: "Anmelden · Kita-Planer" }; export const metadata = { title: "Anmelden · Kita-Planer" };
export default async function LoginPage() { type LoginPageProps = {
searchParams?: Promise<{ callbackUrl?: string | string[] }>;
};
function getSafeCallbackUrl(value: string | string[] | undefined) {
const callbackUrl = Array.isArray(value) ? value[0] : value;
if (!callbackUrl) return "/dashboard";
if (!callbackUrl.startsWith("/") || callbackUrl.startsWith("//")) {
return "/dashboard";
}
return callbackUrl;
}
export default async function LoginPage({ searchParams }: LoginPageProps) {
const session = await auth(); const session = await auth();
if (session?.user?.id) { if (session?.user?.id) {
redirect(getPostLoginRedirect(session.user)); redirect(getPostLoginRedirect(session.user));
} }
const resolvedSearchParams = await searchParams;
const callbackUrl = getSafeCallbackUrl(resolvedSearchParams?.callbackUrl);
return ( return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12"> <div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
@@ -29,7 +44,7 @@ export default async function LoginPage() {
<CardDescription>Willkommen zurück.</CardDescription> <CardDescription>Willkommen zurück.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<LoginForm /> <LoginForm callbackUrl={callbackUrl} />
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">
Noch keinen Account?{" "} Noch keinen Account?{" "}
<Link href="/register" className="font-medium text-foreground underline"> <Link href="/register" className="font-medium text-foreground underline">
+4
View File
@@ -46,3 +46,7 @@ export function getWorkingDaysOfMonth(year: number, month: number) {
.filter((day) => !isWeekend(day)) .filter((day) => !isWeekend(day))
.map((day) => startOfDay(day)); .map((day) => startOfDay(day));
} }
export function formatDateKey(date: Date) {
return format(date, "yyyy-MM-dd");
}