Fix live test follow-up issues
This commit is contained in:
@@ -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,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user