Add non-destructive dev seed on startup

This commit is contained in:
t.indorf
2026-05-06 22:31:07 +02:00
parent 9f452fccac
commit b686e714ff
77 changed files with 10862 additions and 87 deletions
+34
View File
@@ -0,0 +1,34 @@
"use server";
import { revalidatePath } from "next/cache";
import { NotdienstAlertStatus } from "@prisma/client";
import { prisma } from "@/lib/prisma";
export async function confirmAlertAction(token: string) {
try {
const alert = await prisma.notdienstAlert.findUnique({
where: { confirmationToken: token },
});
if (!alert) {
return { error: "Ungültiger Bestätigungs-Link." };
}
if (alert.status !== NotdienstAlertStatus.PENDING) {
return { error: "Dieser Alarm wurde bereits bestätigt oder abgebrochen." };
}
await prisma.notdienstAlert.update({
where: { id: alert.id },
data: {
status: NotdienstAlertStatus.CONFIRMED,
confirmedAt: new Date(),
},
});
revalidatePath("/dashboard");
return { success: true };
} catch (error: any) {
return { error: "Fehler bei der Bestätigung." };
}
}
@@ -0,0 +1,37 @@
"use client";
import { useTransition } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { confirmAlertAction } from "./actions";
export function AlertConfirmForm({ token }: { token: string }) {
const [isPending, startTransition] = useTransition();
const handleConfirm = () => {
startTransition(async () => {
const result = await confirmAlertAction(token);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
toast.success("Erfolgreich bestätigt!");
}
});
};
return (
<Button
size="lg"
className="w-full sm:w-auto font-semibold text-lg"
onClick={handleConfirm}
disabled={isPending}
>
{isPending ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : null}
Ich bestätige! (Bin auf dem Weg)
</Button>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { notFound } from "next/navigation";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { BellRing, ShieldCheck, XCircle } from "lucide-react";
import { NotdienstAlertStatus } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { AlertConfirmForm } from "./alert-confirm-form";
export const metadata = { title: "Notdienst Alarm · Kita-Planer" };
export default async function AlertPage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
const alert = await prisma.notdienstAlert.findUnique({
where: { confirmationToken: token },
include: {
parentUser: true,
kita: true,
assignment: { include: { child: true } },
},
});
if (!alert) {
notFound();
}
const isPending = alert.status === NotdienstAlertStatus.PENDING;
const isConfirmed = alert.status === NotdienstAlertStatus.CONFIRMED;
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/30 px-4 py-12">
<Card className="w-full max-w-md shadow-lg border-primary/20">
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 text-primary">
{isPending ? (
<BellRing className="h-8 w-8 animate-pulse text-destructive" />
) : isConfirmed ? (
<ShieldCheck className="h-8 w-8 text-success" />
) : (
<XCircle className="h-8 w-8 text-muted-foreground" />
)}
</div>
<CardTitle className="text-2xl">
{isPending
? "Notdienst Einsatz!"
: isConfirmed
? "Einsatz bestätigt"
: "Alarm abgebrochen"}
</CardTitle>
<CardDescription className="text-base mt-2">
Kita {alert.kita.name}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-4">
<div className="rounded-lg bg-background border p-4 text-center space-y-1">
<p className="text-sm text-muted-foreground">Datum</p>
<p className="font-semibold text-lg">
{format(alert.assignment.date, "EEEE, dd. MMMM yyyy", {
locale: de,
})}
</p>
<div className="h-2" />
<p className="text-sm text-muted-foreground">Eingeteiltes Kind</p>
<p className="font-medium">
{alert.assignment.child.firstName}{" "}
{alert.assignment.child.lastName}
</p>
</div>
<div className="text-center">
{isPending ? (
<>
<p className="mb-6 text-sm text-muted-foreground">
Hallo {alert.parentUser.firstName}, eine Fachkraft hat sich
krankgemeldet und du bist heute für den Notdienst eingeteilt.
Bitte bestätige deinen Einsatz schnellstmöglich!
</p>
<AlertConfirmForm token={token} />
</>
) : isConfirmed ? (
<p className="text-sm text-muted-foreground">
Danke, {alert.parentUser.firstName}! Dein Einsatz wurde
erfolgreich bestätigt. Die Kita rechnet mit dir.
</p>
) : (
<p className="text-sm text-muted-foreground">
Dieser Einsatz wurde vom Koordinator abgebrochen. Du musst nicht
einspringen.
</p>
)}
</div>
</CardContent>
</Card>
</div>
);
}
+4
View File
@@ -0,0 +1,4 @@
import { handlers } from "@/auth";
// NextAuth v5: Die fertigen Handler werden direkt re-exportiert.
export const { GET, POST } = handlers;
+125
View File
@@ -0,0 +1,125 @@
"use server";
import crypto from "crypto";
import { createElement } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { revalidatePath } from "next/cache";
import { NotdienstAlertStatus, UserRole } from "@prisma/client";
import { AlertEmail } from "@/emails/AlertEmail";
import { requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma";
const BASE_URL =
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
export async function triggerAlertAction(
assignmentId: string,
parentUserId: string,
) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
try {
const [existing, assignment] = await Promise.all([
// Prüfen ob es schon einen aktiven Alert für dieses Assignment gibt
prisma.notdienstAlert.findFirst({
where: { assignmentId, kitaId },
}),
prisma.notdienstAssignment.findFirst({
where: { id: assignmentId, kitaId },
include: {
child: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
},
}),
]);
if (existing) {
return { error: "Für diese Einteilung wurde bereits ein Alarm ausgelöst." };
}
if (!assignment) {
return { error: "Notdienst-Einteilung wurde nicht gefunden." };
}
if (!parentUserId) {
return { error: "Kein Elternteil für diesen Notdienst hinterlegt." };
}
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { error: mailConfigError };
}
const parentLink = await prisma.childParent.findFirst({
where: {
kitaId,
childId: assignment.child.id,
userId: parentUserId,
},
include: {
user: {
select: {
email: true,
firstName: true,
lastName: true,
},
},
},
});
if (!parentLink) {
return { error: "Das Elternteil passt nicht zu dieser Einteilung." };
}
const token = crypto.randomUUID();
await prisma.notdienstAlert.create({
data: {
kitaId,
assignmentId,
parentUserId,
triggeredById: session.user.id,
status: NotdienstAlertStatus.PENDING,
confirmationToken: token,
},
});
const alertUrl = `${BASE_URL}/alert/${token}`;
const dateLabel = format(assignment.date, "EEEE, dd. MMMM yyyy", {
locale: de,
});
const childName = `${assignment.child.firstName} ${assignment.child.lastName}`;
const emailResult = await sendAppEmail({
to: parentLink.user.email,
subject: `Dringender Notdienst-Alarm für ${dateLabel}`,
react: createElement(AlertEmail, {
date: dateLabel,
childName,
confirmLink: alertUrl,
}),
});
revalidatePath("/dashboard");
if (!emailResult.success) {
return {
error: `Alarm wurde angelegt, aber die E-Mail konnte nicht versendet werden: ${emailResult.error}`,
};
}
return { success: true };
} catch (error) {
console.error(error);
return { error: "Fehler beim Auslösen des Alarms." };
}
}
+106
View File
@@ -0,0 +1,106 @@
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { Contact, Mail, Phone, Baby } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
export default async function AdressbuchPage() {
const session = await requireKitaSession();
// Fetch only users who opted in to the directory
const users = await prisma.user.findMany({
where: {
kitaId: session.user.kitaId,
directoryOptInAt: { not: null },
},
include: {
childLinks: {
include: { child: true },
},
dutyAssignments: {
include: { duty: true },
},
},
orderBy: { lastName: "asc" },
});
return (
<div className="flex h-full flex-col gap-6 p-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Adressbuch</h1>
<p className="text-muted-foreground">
Kontaktinformationen aller Eltern, die der Freigabe zugestimmt haben.
</p>
</div>
{users.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50">
<Contact className="h-10 w-10 text-muted-foreground mb-4" />
<h3 className="mt-4 text-lg font-semibold">Keine Kontakte</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
Bisher hat niemand der Veröffentlichung im Adressbuch zugestimmt.
</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{users.map((u) => (
<Card key={u.id} className="flex flex-col">
<CardHeader className="pb-3">
<CardTitle className="flex justify-between items-start">
<span className="text-lg">
{u.firstName} {u.lastName}
</span>
{u.role === "ADMIN" || u.role === "KOORDINATOR" ? (
<Badge variant="secondary" className="text-[10px]">Vorstand</Badge>
) : null}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm flex-1">
<div className="flex items-center gap-2 text-muted-foreground">
<Mail className="h-4 w-4 shrink-0" />
<a href={`mailto:${u.email}`} className="hover:text-primary transition-colors truncate">
{u.email}
</a>
</div>
{u.phone && (
<div className="flex items-center gap-2 text-muted-foreground">
<Phone className="h-4 w-4 shrink-0" />
<a href={`tel:${u.phone}`} className="hover:text-primary transition-colors">
{u.phone}
</a>
</div>
)}
{u.childLinks.length > 0 && (
<div className="flex items-start gap-2 text-muted-foreground mt-1">
<Baby className="h-4 w-4 shrink-0 mt-0.5" />
<div className="flex flex-wrap gap-1">
{u.childLinks.map(link => (
<span key={link.child.id} className="bg-muted px-1.5 py-0.5 rounded-md text-xs">
{link.child.firstName}
</span>
))}
</div>
</div>
)}
{u.dutyAssignments.length > 0 && (
<div className="mt-auto pt-3 border-t">
<span className="text-xs font-medium text-muted-foreground mb-1 block">Ämter / Dienste:</span>
<div className="flex flex-wrap gap-1">
{u.dutyAssignments.map((assignment) => (
<Badge key={assignment.duty.id} variant="outline" className="bg-primary/5 text-primary border-primary/20">
{assignment.duty.name}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useTransition } from "react";
import { Loader2, BellRing } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { triggerAlertAction } from "./actions";
export function AlertButton({
assignmentId,
parentUserId,
}: {
assignmentId: string;
parentUserId: string;
}) {
const [isPending, startTransition] = useTransition();
const handleAlert = () => {
if (!confirm("Bist du sicher? Dies löst den Notdienst-Alarm aus.")) return;
startTransition(async () => {
const result = await triggerAlertAction(assignmentId, parentUserId);
if ("error" in result && result.error) {
toast.error(result.error);
} else {
toast.success("Alarm erfolgreich ausgelöst. E-Mail wurde versendet.");
}
});
};
return (
<Button variant="destructive" onClick={handleAlert} disabled={isPending}>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<BellRing className="mr-2 h-4 w-4" />
)}
Alarm auslösen
</Button>
);
}
@@ -0,0 +1,202 @@
"use client";
import { useState, useTransition } from "react";
import { Educator } from "@prisma/client";
import { Plus, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { createEducator, updateEducator, deleteEducator } from "../actions";
export function ErzieherList({ educators }: { educators: Educator[] }) {
const [openCreate, setOpenCreate] = useState(false);
const [editingEducator, setEditingEducator] = useState<Educator | null>(null);
const [isPending, startTransition] = useTransition();
const handleCreate = (formData: FormData) => {
const data = {
firstName: formData.get("firstName") as string,
lastName: formData.get("lastName") as string,
active: formData.get("active") === "on",
};
startTransition(async () => {
const res = await createEducator(data);
if (res.error) {
toast.error(res.error);
} else {
toast.success("ErzieherIn angelegt.");
setOpenCreate(false);
}
});
};
const handleUpdate = (formData: FormData) => {
if (!editingEducator) return;
const data = {
firstName: formData.get("firstName") as string,
lastName: formData.get("lastName") as string,
active: formData.get("active") === "on",
};
startTransition(async () => {
const res = await updateEducator(editingEducator.id, data);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Daten aktualisiert.");
setEditingEducator(null);
}
});
};
const handleDelete = (id: string) => {
if (!confirm("Wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.")) return;
startTransition(async () => {
const res = await deleteEducator(id);
if (res.error) {
toast.error(res.error);
} else {
toast.success("ErzieherIn gelöscht.");
}
});
};
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<Dialog open={openCreate} onOpenChange={setOpenCreate}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus className="h-4 w-4" />
ErzieherIn anlegen
</Button>
</DialogTrigger>
<DialogContent>
<form action={handleCreate}>
<DialogHeader>
<DialogTitle>Neue(n) ErzieherIn anlegen</DialogTitle>
<DialogDescription>
Erfasse die Stammdaten des Kita-Personals.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="firstName">Vorname</Label>
<Input id="firstName" name="firstName" required />
</div>
<div className="grid gap-2">
<Label htmlFor="lastName">Nachname</Label>
<Input id="lastName" name="lastName" required />
</div>
<div className="flex items-center gap-2 mt-2">
<Switch id="active" name="active" defaultChecked />
<Label htmlFor="active">Aktiv (Arbeitet aktuell hier)</Label>
</div>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpenCreate(false)}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>Speichern</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
<div className="rounded-md border bg-background">
<Table>
<TableHeader>
<TableRow>
<TableHead>Vorname</TableHead>
<TableHead>Nachname</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{educators.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground h-24">
Keine Einträge gefunden.
</TableCell>
</TableRow>
) : (
educators.map((ed) => (
<TableRow key={ed.id}>
<TableCell className="font-medium">{ed.firstName}</TableCell>
<TableCell>{ed.lastName}</TableCell>
<TableCell>
<Badge variant={ed.active ? "success" : "secondary"}>
{ed.active ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" onClick={() => setEditingEducator(ed)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="text-destructive hover:text-destructive" onClick={() => handleDelete(ed.id)} disabled={isPending}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Edit Dialog */}
<Dialog open={!!editingEducator} onOpenChange={(open) => !open && setEditingEducator(null)}>
<DialogContent>
<form action={handleUpdate}>
<DialogHeader>
<DialogTitle>ErzieherIn bearbeiten</DialogTitle>
</DialogHeader>
{editingEducator && (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-firstName">Vorname</Label>
<Input id="edit-firstName" name="firstName" defaultValue={editingEducator.firstName} required />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-lastName">Nachname</Label>
<Input id="edit-lastName" name="lastName" defaultValue={editingEducator.lastName} required />
</div>
<div className="flex items-center gap-2 mt-2">
<Switch id="edit-active" name="active" defaultChecked={editingEducator.active} />
<Label htmlFor="edit-active">Aktiv</Label>
</div>
</div>
)}
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setEditingEducator(null)}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>Aktualisieren</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use server";
import { revalidatePath } from "next/cache";
import { UserRole } from "@prisma/client";
import { z } from "zod";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
const educatorSchema = z.object({
firstName: z.string().min(1, "Vorname ist erforderlich"),
lastName: z.string().min(1, "Nachname ist erforderlich"),
active: z.boolean().default(true),
});
export async function createEducator(rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN]);
const parsed = educatorSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
try {
await prisma.educator.create({
data: {
kitaId: session.user.kitaId!,
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
active: parsed.data.active,
},
});
revalidatePath("/dashboard/erzieher");
return { success: true };
} catch (error) {
console.error("Fehler beim Anlegen:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
export async function updateEducator(id: string, rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN]);
const parsed = educatorSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
try {
await prisma.educator.update({
where: {
id,
kitaId: session.user.kitaId!,
},
data: {
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
active: parsed.data.active,
},
});
revalidatePath("/dashboard/erzieher");
return { success: true };
} catch (error) {
console.error("Fehler beim Aktualisieren:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
export async function deleteEducator(id: string) {
const session = await requireRole([UserRole.ADMIN]);
try {
await prisma.educator.delete({
where: {
id,
kitaId: session.user.kitaId!,
},
});
revalidatePath("/dashboard/erzieher");
return { success: true };
} catch (error) {
console.error("Fehler beim Löschen:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
+29
View File
@@ -0,0 +1,29 @@
import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { ErzieherList } from "./_components/erzieher-list";
export const metadata = { title: "ErzieherInnen · Kita-Planer" };
export default async function ErzieherPage() {
const session = await requireRole([UserRole.ADMIN]);
const educators = await prisma.educator.findMany({
where: { kitaId: session.user.kitaId! },
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
});
return (
<div className="flex h-full flex-col gap-6 p-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">ErzieherInnen-Verwaltung</h1>
<p className="text-muted-foreground">
Verwalte die Stammdaten des Kita-Personals (nur für den Vorstand sichtbar).
</p>
</div>
<ErzieherList educators={educators} />
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
"use server";
import crypto from "crypto";
import { createElement } from "react";
import { Prisma, UserRole } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireRole } from "@/lib/auth-utils";
import { InviteEmail } from "@/emails/InviteEmail";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
// =====================================================================
// /dashboard/families · Server Actions
// ---------------------------------------------------------------------
// addFamilyAction: Erstellt Elternteil + Kinder + VerificationToken in
// einer atomaren Prisma-Transaktion. Die kitaId kommt ausschließlich
// aus der validierten Session — nie aus dem Formular-Input.
// =====================================================================
const parentSchema = z.object({
firstName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
lastName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
email: z
.string()
.email("Bitte eine gültige E-Mail-Adresse angeben.")
.toLowerCase()
.trim(),
childCount: z.coerce
.number()
.int()
.min(1, "Mindestens ein Kind erforderlich.")
.max(10),
});
const childSchema = z.object({
firstName: z.string().min(1, "Vorname des Kindes fehlt.").max(100).trim(),
lastName: z.string().min(1, "Nachname des Kindes fehlt.").max(100).trim(),
});
export type AddFamilyState = {
errors?: {
firstName?: string[];
lastName?: string[];
email?: string[];
children?: string[];
_form?: string[];
};
success?: boolean;
};
const PRIVACY_POLICY_VERSION = "2026-05-01";
const INVITE_TOKEN_TTL_DAYS = 7;
const BASE_URL =
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
export async function addFamilyAction(
_prev: AddFamilyState,
formData: FormData,
): Promise<AddFamilyState> {
// ── 1. Nur Admins dürfen Familien anlegen ──────────────────────────
const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]);
// ── 2. Parent-Felder validieren ────────────────────────────────────
const parsedParent = parentSchema.safeParse(Object.fromEntries(formData));
if (!parsedParent.success) {
return { errors: parsedParent.error.flatten().fieldErrors };
}
const { firstName, lastName, email, childCount } = parsedParent.data;
// ── 3. Kinder-Felder validieren ────────────────────────────────────
const childrenRaw: { firstName: string; lastName: string }[] = [];
for (let i = 0; i < childCount; i++) {
const parsed = childSchema.safeParse({
firstName: formData.get(`childFirstName_${i}`),
lastName: formData.get(`childLastName_${i}`),
});
if (!parsed.success) {
return {
errors: { children: [`Kind ${i + 1}: ${Object.values(parsed.error.flatten().fieldErrors).flat().join(", ")}`] },
};
}
childrenRaw.push(parsed.data);
}
// ── 4. Datenbank-Transaktion ───────────────────────────────────────
// kitaId kommt ausschließlich aus der Session (Mandanten-Isolation!).
// SUPERADMIN hat keine kitaId → dieser Pfad sollte nie erreicht werden,
// aber wir prüfen explizit, um den Typ zu narrowen.
const kitaId = session.user.kitaId;
if (!kitaId) {
return { errors: { _form: ["Kein Mandant zugeordnet."] } };
}
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { errors: { _form: [mailConfigError] } };
}
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
});
if (!kita) {
return { errors: { _form: ["Kita wurde nicht gefunden."] } };
}
const parentName = `${firstName} ${lastName}`;
const token = crypto.randomUUID();
const inviteUrl = `${BASE_URL}/invite/${token}`;
const expires = new Date(
Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000,
);
try {
await prisma.$transaction(async (tx) => {
// 4a. Elternteil anlegen (kein Passwort → leerer passwordHash)
const parent = await tx.user.create({
data: {
email,
firstName,
lastName,
passwordHash: "", // wird beim Invite-Einlösen gesetzt
role: UserRole.ELTERN,
kitaId,
},
});
// 4b. Kinder anlegen + mit Elternteil verknüpfen
for (const child of childrenRaw) {
const createdChild = await tx.child.create({
data: {
kitaId,
firstName: child.firstName,
lastName: child.lastName,
},
});
await tx.childParent.create({
data: {
kitaId,
childId: createdChild.id,
userId: parent.id,
},
});
}
// 4c. Einladungs-Token erstellen
// identifier = userId (kein PII im Token selbst)
await tx.verificationToken.create({
data: {
identifier: parent.id,
token,
expires,
},
});
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
return {
errors: {
email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."],
},
};
}
throw err;
}
const emailResult = await sendAppEmail({
to: email,
subject: `Einladung zu ${kita.name} im Kita-Planer`,
react: createElement(InviteEmail, {
parentName,
kitaName: kita.name,
inviteLink: inviteUrl,
}),
});
if (!emailResult.success) {
return {
errors: {
_form: [
`Familie wurde angelegt, aber die Einladung konnte nicht versendet werden: ${emailResult.error}`,
],
},
};
}
return { success: true };
}
@@ -0,0 +1,225 @@
"use client";
import { useActionState, useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { addFamilyAction, type AddFamilyState } from "./actions";
const initialState: AddFamilyState = {};
// =====================================================================
// AddFamilyDialog · Client Component
// ---------------------------------------------------------------------
// Verwaltet:
// • Dialog-Open/Close-State
// • Dynamische Kinderliste (min. 1, max. 10)
// • useActionState → Server-Action-Fehler anzeigen
// • Bei Erfolg (state.success) Dialog automatisch schließen
// =====================================================================
export function AddFamilyDialog() {
const [open, setOpen] = useState(false);
const [childCount, setChildCount] = useState(1);
const [state, formAction, pending] = useActionState(
addFamilyAction,
initialState,
);
// Dialog schließen, wenn die Action erfolgreich war
if (state.success && open) {
setOpen(false);
}
function handleOpenChange(nextOpen: boolean) {
setOpen(nextOpen);
// State beim Schließen zurücksetzen würde useActionState erfordern —
// stattdessen schließen wir einfach und der State bleibt bis zur nächsten
// Aktion erhalten (unkritisch).
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="mr-2 h-4 w-4" />
Familie hinzufügen
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Familie hinzufügen</DialogTitle>
<DialogDescription>
Lege das Elternteil und die zugehörigen Kinder an. Der Elternteil
erhält einen Einladungslink per E-Mail, um sein Passwort zu setzen.
</DialogDescription>
</DialogHeader>
<form action={formAction} className="space-y-6">
{/* Anzahl Kinder als verstecktes Feld für die Server Action */}
<input type="hidden" name="childCount" value={childCount} />
{/* ── Elternteil ─────────────────────────────────────────── */}
<fieldset className="space-y-4">
<legend className="text-sm font-semibold">Elternteil</legend>
<div className="grid grid-cols-2 gap-3">
<FormField
id="firstName"
name="firstName"
label="Vorname"
autoComplete="given-name"
error={state.errors?.firstName?.[0]}
/>
<FormField
id="lastName"
name="lastName"
label="Nachname"
autoComplete="family-name"
error={state.errors?.lastName?.[0]}
/>
</div>
<FormField
id="email"
name="email"
type="email"
label="E-Mail-Adresse"
autoComplete="email"
error={state.errors?.email?.[0]}
/>
</fieldset>
{/* ── Kinder ─────────────────────────────────────────────── */}
<fieldset className="space-y-3">
<div className="flex items-center justify-between">
<legend className="text-sm font-semibold">
Kinder ({childCount})
</legend>
{childCount < 10 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setChildCount((c) => c + 1)}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Kind hinzufügen
</Button>
)}
</div>
{Array.from({ length: childCount }).map((_, i) => (
<div
key={i}
className="relative rounded-md border p-4"
>
{childCount > 1 && (
<button
type="button"
onClick={() => {
// Kind entfernen — childCount reduzieren reicht, da die
// Felder nach Index benannt sind und der letzte entfernt wird.
setChildCount((c) => c - 1);
}}
className="absolute right-3 top-3 rounded p-0.5 text-muted-foreground hover:text-destructive"
aria-label={`Kind ${i + 1} entfernen`}
>
<Trash2 className="h-4 w-4" />
</button>
)}
<p className="mb-3 text-xs font-medium text-muted-foreground">
Kind {i + 1}
</p>
<div className="grid grid-cols-2 gap-3">
<FormField
id={`childFirstName_${i}`}
name={`childFirstName_${i}`}
label="Vorname"
/>
<FormField
id={`childLastName_${i}`}
name={`childLastName_${i}`}
label="Nachname"
/>
</div>
</div>
))}
{state.errors?.children?.[0] && (
<p className="text-xs text-destructive">
{state.errors.children[0]}
</p>
)}
</fieldset>
{/* Globaler Fehler */}
{state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{state.errors._form[0]}
</p>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={pending}
>
Abbrechen
</Button>
<Button type="submit" disabled={pending}>
{pending ? "Wird angelegt…" : "Familie anlegen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
// ── Hilfskomponente ────────────────────────────────────────────────────
function FormField({
id,
name,
label,
type = "text",
autoComplete,
error,
}: {
id: string;
name: string;
label: string;
type?: string;
autoComplete?: string;
error?: string;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
name={name}
type={type}
autoComplete={autoComplete}
required
aria-invalid={!!error}
/>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use server";
import { revalidatePath } from "next/cache";
import { UserRole } from "@prisma/client";
import { z } from "zod";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
const dutySchema = z.object({
name: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein").max(50),
description: z.string().optional(),
});
export async function createDuty(rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const parsed = dutySchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
try {
await prisma.parentDuty.create({
data: {
kitaId: session.user.kitaId!,
name: parsed.data.name,
description: parsed.data.description,
},
});
revalidatePath("/dashboard/families");
revalidatePath("/dashboard/adressbuch");
return { success: true };
} catch (error) {
console.error("Fehler beim Erstellen des Amtes:", error);
return { error: "Ein Fehler ist aufgetreten (evtl. existiert der Name bereits)." };
}
}
export async function deleteDuty(dutyId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
try {
await prisma.parentDuty.delete({
where: {
id: dutyId,
kitaId: session.user.kitaId!,
},
});
revalidatePath("/dashboard/families");
revalidatePath("/dashboard/adressbuch");
return { success: true };
} catch (error) {
console.error("Fehler beim Löschen des Amtes:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
export async function assignDuty(userId: string, dutyId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
try {
// Check if assignment already exists
const existing = await prisma.parentDutyAssignment.findUnique({
where: {
dutyId_userId: { dutyId, userId },
},
});
if (existing) {
return { error: "Der Nutzer hat dieses Amt bereits." };
}
await prisma.parentDutyAssignment.create({
data: {
kitaId: session.user.kitaId!,
userId: userId,
dutyId: dutyId,
},
});
revalidatePath("/dashboard/families");
revalidatePath("/dashboard/adressbuch");
return { success: true };
} catch (error) {
console.error("Fehler beim Zuweisen des Amtes:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
export async function removeDutyAssignment(assignmentId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
try {
await prisma.parentDutyAssignment.delete({
where: {
id: assignmentId,
kitaId: session.user.kitaId!,
},
});
revalidatePath("/dashboard/families");
revalidatePath("/dashboard/adressbuch");
return { success: true };
} catch (error) {
console.error("Fehler beim Entfernen des Amtes:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
+186
View File
@@ -0,0 +1,186 @@
"use client";
import { useState, useTransition } from "react";
import { Plus, X, Shield, Trash2 } from "lucide-react";
import { ParentDuty, ParentDutyAssignment, User } from "@prisma/client";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { createDuty, assignDuty, removeDutyAssignment, deleteDuty } from "./duty-actions";
type DutyWithAssignments = ParentDuty & {
assignments: ParentDutyAssignment[];
};
export function DutyManager({
user,
allDuties,
userAssignments,
}: {
user: User;
allDuties: DutyWithAssignments[];
userAssignments: (ParentDutyAssignment & { duty: ParentDuty })[];
}) {
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const handleCreateDuty = (formData: FormData) => {
const name = formData.get("name") as string;
if (!name) return;
startTransition(async () => {
const res = await createDuty({ name });
if (res.error) {
toast.error(res.error);
} else {
toast.success("Amt erstellt.");
}
});
};
const handleAssign = (dutyId: string) => {
startTransition(async () => {
const res = await assignDuty(user.id, dutyId);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Amt zugewiesen.");
}
});
};
const handleRemove = (assignmentId: string) => {
startTransition(async () => {
const res = await removeDutyAssignment(assignmentId);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Zuweisung entfernt.");
}
});
};
const handleDeleteDuty = (dutyId: string) => {
startTransition(async () => {
const res = await deleteDuty(dutyId);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Amt komplett gelöscht.");
}
});
};
const unassignedDuties = allDuties.filter(
(d) => !userAssignments.some((a) => a.dutyId === d.id)
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1">
<Shield className="h-4 w-4" />
<span className="hidden sm:inline">Ämter</span>
{userAssignments.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1 py-0 h-4">
{userAssignments.length}
</Badge>
)}
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Ämter verwalten</DialogTitle>
<DialogDescription>
Ämter und Dienste für {user.firstName} {user.lastName}.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-6 py-4">
{/* Current Assignments */}
<div className="flex flex-col gap-2">
<h4 className="text-sm font-semibold">Aktuelle Ämter</h4>
{userAssignments.length === 0 ? (
<p className="text-sm text-muted-foreground italic">Keine Ämter zugewiesen.</p>
) : (
<div className="flex flex-wrap gap-2">
{userAssignments.map((assignment) => (
<Badge key={assignment.id} variant="default" className="gap-1 pr-1 bg-primary">
{assignment.duty.name}
<button
className="ml-1 rounded-full hover:bg-primary-foreground/20 p-0.5"
onClick={() => handleRemove(assignment.id)}
disabled={isPending}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
{/* Assign new duty */}
<div className="flex flex-col gap-2 border-t pt-4">
<h4 className="text-sm font-semibold">Vorhandenes Amt zuweisen</h4>
{unassignedDuties.length === 0 ? (
<p className="text-sm text-muted-foreground italic">Keine weiteren Ämter verfügbar.</p>
) : (
<div className="flex flex-wrap gap-2">
{unassignedDuties.map((duty) => (
<Badge
key={duty.id}
variant="outline"
className="cursor-pointer hover:bg-muted gap-1 pr-1 group relative"
onClick={() => handleAssign(duty.id)}
>
<Plus className="h-3 w-3" />
{duty.name}
{/* Only show delete if no one is assigned to it across the entire DB */}
{duty.assignments.length === 0 && (
<button
className="ml-1 rounded-full hover:bg-destructive/20 text-destructive p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
handleDeleteDuty(duty.id);
}}
disabled={isPending}
title="Amt löschen"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</Badge>
))}
</div>
)}
</div>
{/* Create new duty */}
<div className="flex flex-col gap-2 border-t pt-4">
<h4 className="text-sm font-semibold">Neues Amt anlegen</h4>
<form action={handleCreateDuty} className="flex gap-2">
<Input name="name" placeholder="z.B. Wäschedienst" className="h-8" required />
<Button size="sm" className="h-8" disabled={isPending}>
Erstellen
</Button>
</form>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+173
View File
@@ -0,0 +1,173 @@
import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { AddFamilyDialog } from "./add-family-dialog";
import { DutyManager } from "./duty-manager";
export const metadata = { title: "Familienverwaltung · Kita-Planer" };
// =====================================================================
// /dashboard/families · Server Component
// ---------------------------------------------------------------------
// Zeigt alle Elternteile (ELTERN) und deren Kinder für die aktuelle
// kitaId. Tenant-Filter ist garantiert: kitaId aus requireRole-Session.
// =====================================================================
export default async function FamiliesPage() {
// Guard: Nur Admins (und Koordinatoren) dürfen diese Seite sehen
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
// Alle ELTERN-User der Kita mit ihren verknüpften Kindern laden
// kitaId IMMER aus der Session — nie aus URL/Params
const families = await prisma.user.findMany({
where: {
kitaId: session.user.kitaId!,
role: UserRole.ELTERN,
},
select: {
id: true,
firstName: true,
lastName: true,
email: true,
passwordHash: true, // "" → Invite ausstehend; sonst aktiv
emailVerifiedAt: true,
childLinks: {
select: {
child: {
select: { id: true, firstName: true, lastName: true },
},
},
},
dutyAssignments: {
include: { duty: true },
},
},
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
});
const allDuties = await prisma.parentDuty.findMany({
where: { kitaId: session.user.kitaId! },
include: { assignments: true },
orderBy: { name: "asc" },
});
return (
<div className="px-8 py-8">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Familienverwaltung
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{families.length === 0
? "Noch keine Familien angelegt."
: `${families.length} ${families.length === 1 ? "Familie" : "Familien"} · ${families.reduce((acc, f) => acc + f.childLinks.length, 0)} Kinder`}
</p>
</div>
<AddFamilyDialog />
</div>
{families.length === 0 ? (
<EmptyState />
) : (
<div className="rounded-lg border bg-background">
<Table>
<TableHeader>
<TableRow>
<TableHead>Elternteil</TableHead>
<TableHead>E-Mail</TableHead>
<TableHead>Kinder</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{families.map((family) => {
const isActive = !!family.emailVerifiedAt;
const children = family.childLinks.map((l) => l.child);
return (
<TableRow key={family.id}>
<TableCell className="font-medium">
{family.firstName} {family.lastName}
</TableCell>
<TableCell className="text-muted-foreground">
{family.email}
</TableCell>
<TableCell>
{children.length === 0 ? (
<span className="text-sm text-muted-foreground"></span>
) : (
<div className="flex flex-wrap gap-1">
{children.map((child) => (
<span
key={child.id}
className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium"
>
{child.firstName} {child.lastName}
</span>
))}
</div>
)}
</TableCell>
<TableCell>
<Badge variant={isActive ? "success" : "warning"}>
{isActive ? "Aktiv" : "Eingeladen"}
</Badge>
</TableCell>
<TableCell className="text-right">
<DutyManager
user={family as any}
allDuties={allDuties as any}
userAssignments={family.dutyAssignments}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
);
}
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-background px-6 py-16 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<svg
className="h-6 w-6 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
</div>
<h3 className="mb-1 font-semibold">Noch keine Familien</h3>
<p className="mb-4 max-w-sm text-sm text-muted-foreground">
Lege die erste Familie an und lade das Elternteil per Link ein, sein
Passwort zu setzen.
</p>
<AddFamilyDialog />
</div>
);
}
@@ -0,0 +1,128 @@
"use client";
import { useState, useTransition } from "react";
import { TerminType } from "@prisma/client";
import { Plus } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { createTerminAdmin } from "../actions";
import { Textarea } from "@/components/ui/textarea";
export function AdminTerminModal() {
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const handleAction = (formData: FormData) => {
const data = {
title: formData.get("title") as string,
description: formData.get("description") as string,
type: formData.get("type") as TerminType,
startDate: new Date(formData.get("startDate") as string).toISOString(),
endDate: new Date(formData.get("endDate") as string).toISOString(),
allDay: formData.get("allDay") === "on",
};
startTransition(async () => {
const res = await createTerminAdmin(data);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Termin wurde direkt angelegt!");
setOpen(false);
}
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="gap-2 bg-primary">
<Plus className="h-4 w-4" />
Termin direkt anlegen
</Button>
</DialogTrigger>
<DialogContent>
<form action={handleAction}>
<DialogHeader>
<DialogTitle>Termin anlegen (Admin)</DialogTitle>
<DialogDescription>
Dieser Termin wird sofort freigegeben und ist für alle sichtbar.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="title">Titel</Label>
<Input id="title" name="title" required placeholder="z.B. Sommerfest" />
</div>
<div className="grid gap-2">
<Label htmlFor="description">Beschreibung (Optional)</Label>
<Textarea id="description" name="description" placeholder="Details zum Termin..." />
</div>
<div className="grid gap-2">
<Label htmlFor="type">Kategorie</Label>
<select
id="type"
name="type"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
required
>
<option value={TerminType.KITA_FEST}>Kita-Fest</option>
<option value={TerminType.SCHLIESSTAG}>Schließtag</option>
<option value={TerminType.TEAMTAG}>Teamtag (Kita geschlossen)</option>
<option value={TerminType.MITMACH_TAG}>Mitmach-Tag</option>
<option value={TerminType.ELTERNABEND}>Elternabend</option>
<option value={TerminType.MITGLIEDERVERSAMMLUNG}>Mitgliederversammlung</option>
<option value={TerminType.ELTERNCAFE}>Elterncafe</option>
<option value={TerminType.GEBURTSTAG_INTERN}>Geburtstag (Intern)</option>
<option value={TerminType.GEBURTSTAG_EXTERN}>Raumanfrage (Extern)</option>
<option value={TerminType.SONSTIGES}>Sonstiges</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="startDate">Start</Label>
<Input id="startDate" name="startDate" type="datetime-local" required />
</div>
<div className="grid gap-2">
<Label htmlFor="endDate">Ende</Label>
<Input id="endDate" name="endDate" type="datetime-local" required />
</div>
</div>
<div className="flex items-center gap-2 mt-2">
<input type="checkbox" id="allDay" name="allDay" className="rounded border-gray-300" />
<Label htmlFor="allDay" className="font-normal cursor-pointer">
Ganztägiger Termin
</Label>
</div>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Speichern..." : "Termin erstellen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,119 @@
"use client";
import { useState, useTransition } from "react";
import { MitbringselItem } from "@prisma/client";
import { Trash2, Plus, Utensils } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { addMitbringsel, deleteMitbringsel } from "../actions";
type ItemWithUser = MitbringselItem & {
user: { firstName: string; lastName: string };
};
export function MitbringselList({
terminId,
items,
currentUserId,
isAdmin,
}: {
terminId: string;
items: ItemWithUser[];
currentUserId: string;
isAdmin: boolean;
}) {
const [content, setContent] = useState("");
const [isPending, startTransition] = useTransition();
const handleAdd = () => {
if (!content.trim()) return;
startTransition(async () => {
const res = await addMitbringsel({ terminId, content });
if (res.error) {
toast.error(res.error);
} else {
toast.success("Eintrag hinzugefügt.");
setContent("");
}
});
};
const handleDelete = (id: string) => {
startTransition(async () => {
const res = await deleteMitbringsel(id);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Eintrag gelöscht.");
}
});
};
return (
<div className="flex flex-col gap-3 rounded-md bg-muted/50 p-3">
<div className="flex items-center gap-2 font-medium text-sm mb-1">
<Utensils className="h-4 w-4 text-muted-foreground" />
Mitbring-Liste
</div>
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto">
{items.length === 0 ? (
<p className="text-xs text-muted-foreground italic">Noch keine Einträge vorhanden.</p>
) : (
items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between gap-2 rounded-sm bg-background p-2 text-sm shadow-sm"
>
<div className="flex flex-col">
<span className="font-medium text-xs text-primary">
{item.user.firstName} {item.user.lastName}
</span>
<span>{item.content}</span>
</div>
{(isAdmin || item.userId === currentUserId) && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive shrink-0"
onClick={() => handleDelete(item.id)}
disabled={isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))
)}
</div>
<div className="flex gap-2 mt-1">
<Input
placeholder="Was bringst du mit?"
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAdd();
}
}}
disabled={isPending}
className="h-8 text-sm"
/>
<Button
size="sm"
className="h-8 shrink-0"
onClick={handleAdd}
disabled={!content.trim() || isPending}
>
<Plus className="h-4 w-4 mr-1" />
Hinzufügen
</Button>
</div>
</div>
);
}
@@ -0,0 +1,103 @@
"use client";
import { useTransition } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Check, X, Clock, CalendarIcon } from "lucide-react";
import { toast } from "sonner";
import { Termin, User } from "@prisma/client";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { approveTermin, rejectTermin } from "../actions";
type PendingTermin = Termin & {
createdBy: { firstName: string; lastName: string } | null;
};
export function PendingAnfragen({ termine }: { termine: PendingTermin[] }) {
if (termine.length === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50">
<CalendarIcon className="h-10 w-10 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">Keine ausstehenden Anfragen</h3>
<p className="text-sm text-muted-foreground">
Es gibt aktuell keine Terminanfragen, die bestätigt werden müssen.
</p>
</div>
);
}
return (
<div className="flex flex-col gap-4">
{termine.map((termin) => (
<PendingTerminCard key={termin.id} termin={termin} />
))}
</div>
);
}
function PendingTerminCard({ termin }: { termin: PendingTermin }) {
const [isPending, startTransition] = useTransition();
const handleApprove = () => {
startTransition(async () => {
const res = await approveTermin(termin.id);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Anfrage freigegeben.");
}
});
};
const handleReject = () => {
// In a real app, you might want to ask for a rejection reason in a prompt/modal.
startTransition(async () => {
const res = await rejectTermin(termin.id);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Anfrage abgelehnt.");
}
});
};
return (
<Card>
<CardContent className="flex items-center justify-between p-4 sm:p-6">
<div className="flex flex-col gap-1">
<div className="font-semibold">{termin.title}</div>
<div className="text-sm text-muted-foreground flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{format(termin.startDate, "PP", { locale: de })}
{!termin.allDay && `${format(termin.startDate, "p", { locale: de })}`}
</div>
<div className="text-sm font-medium text-primary mt-1">
Angefragt von: {termin.createdBy?.firstName} {termin.createdBy?.lastName}
</div>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<Button
variant="outline"
className="text-destructive hover:bg-destructive hover:text-destructive-foreground border-destructive/20"
onClick={handleReject}
disabled={isPending}
>
<X className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Ablehnen</span>
</Button>
<Button
className="bg-emerald-600 hover:bg-emerald-700 text-white"
onClick={handleApprove}
disabled={isPending}
>
<Check className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Freigeben</span>
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,214 @@
"use client";
import { Termin, MitbringselItem, TerminType, TerminStatus } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Calendar, Clock, MapPin, AlignLeft } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { MitbringselList } from "./mitbringsel-list";
import { toggleMitbringselList } from "../actions";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useTransition } from "react";
import { toast } from "sonner";
type TerminWithItems = Termin & {
mitbringselItems?: (MitbringselItem & {
user: { firstName: string; lastName: string };
})[];
};
export function TerminList({
termine,
userId,
isAdmin,
}: {
termine: TerminWithItems[];
userId: string;
isAdmin: boolean;
}) {
if (termine.length === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<Calendar className="h-10 w-10 text-muted-foreground mb-4" />
<h3 className="mt-4 text-lg font-semibold">Keine Termine</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
Es stehen aktuell keine Termine an.
</p>
</div>
</div>
);
}
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{termine.map((termin) => (
<TerminCard
key={termin.id}
termin={termin}
userId={userId}
isAdmin={isAdmin}
/>
))}
</div>
);
}
function TerminCard({
termin,
userId,
isAdmin,
}: {
termin: TerminWithItems;
userId: string;
isAdmin: boolean;
}) {
const [isPending, startTransition] = useTransition();
const handleToggleMitbringsel = (enabled: boolean) => {
startTransition(async () => {
const result = await toggleMitbringselList(termin.id, enabled);
if (result.error) {
toast.error(result.error);
} else {
toast.success(
enabled
? "Mitbring-Liste aktiviert"
: "Mitbring-Liste deaktiviert"
);
}
});
};
return (
<Card className="flex flex-col overflow-hidden relative">
{termin.status === TerminStatus.PENDING && (
<div className="absolute top-0 right-0 bg-yellow-500 text-white text-xs font-bold px-2 py-1 rounded-bl-lg z-10">
Ausstehend
</div>
)}
<CardHeader className="pb-3">
<div className="flex justify-between items-start mb-2 gap-2">
<Badge variant={getBadgeVariant(termin.type)} className={getBadgeColor(termin.type)}>
{getTypeLabel(termin.type)}
</Badge>
</div>
<CardTitle className="line-clamp-2 leading-tight">{termin.title}</CardTitle>
<CardDescription className="flex items-center gap-1 mt-1 text-sm">
<Clock className="h-3.5 w-3.5" />
{format(termin.startDate, "PP", { locale: de })}
{!termin.allDay && (
<>
{" • "}
{format(termin.startDate, "p", { locale: de })} -{" "}
{format(termin.endDate, "p", { locale: de })}
</>
)}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 pb-4">
{termin.description && (
<div className="flex items-start gap-2 text-sm text-muted-foreground mt-2">
<AlignLeft className="h-4 w-4 shrink-0 mt-0.5" />
<p className="line-clamp-3 whitespace-pre-wrap">{termin.description}</p>
</div>
)}
{/* Admin Toggle for Mitbringsel-List */}
{isAdmin && termin.status === TerminStatus.CONFIRMED && (
<div className="flex items-center space-x-2 mt-6 pt-4 border-t">
<Switch
id={`mitbringsel-${termin.id}`}
checked={termin.mitbringselListEnabled}
onCheckedChange={handleToggleMitbringsel}
disabled={isPending}
/>
<Label htmlFor={`mitbringsel-${termin.id}`} className="text-xs">
Mitbring-Liste aktiv
</Label>
</div>
)}
{/* Mitbringsel-List UI */}
{termin.mitbringselListEnabled && termin.status === TerminStatus.CONFIRMED && (
<div className="mt-4">
<MitbringselList
terminId={termin.id}
items={termin.mitbringselItems || []}
currentUserId={userId}
isAdmin={isAdmin}
/>
</div>
)}
</CardContent>
</Card>
);
}
function getBadgeVariant(type: TerminType): "default" | "secondary" | "destructive" | "outline" {
switch (type) {
case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG:
return "default";
case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG:
return "destructive";
case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN:
case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "secondary";
default:
return "outline";
}
}
function getBadgeColor(type: TerminType): string {
switch (type) {
case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG:
return "bg-amber-500 hover:bg-amber-600 text-white border-transparent";
case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG:
return "bg-rose-500 hover:bg-rose-600 text-white border-transparent";
case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN:
return "bg-blue-500 hover:bg-blue-600 text-white border-transparent";
case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "bg-purple-500 hover:bg-purple-600 text-white border-transparent";
default:
return "bg-slate-200 text-slate-800 border-slate-300 dark:bg-slate-800 dark:text-slate-200 dark:border-slate-700";
}
}
function getTypeLabel(type: TerminType): string {
switch (type) {
case TerminType.KITA_FEST:
return "Kita-Fest";
case TerminType.MITMACH_TAG:
return "Mitmach-Tag";
case TerminType.SCHLIESSTAG:
return "Schließtag";
case TerminType.TEAMTAG:
return "Teamtag (Kita geschlossen)";
case TerminType.ELTERNABEND:
return "Elternabend";
case TerminType.MITGLIEDERVERSAMMLUNG:
return "Mitgliederversammlung";
case TerminType.ELTERNCAFE:
return "Elterncafe";
case TerminType.GEBURTSTAG_INTERN:
return "Geburtstag (Intern)";
case TerminType.GEBURTSTAG_EXTERN:
return "Raumanfrage (Extern)";
default:
return "Sonstiges";
}
}
@@ -0,0 +1,122 @@
"use client";
import { useState, useTransition } from "react";
import { TerminType } from "@prisma/client";
import { CalendarPlus } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { createTerminRequest } from "../actions";
export function TerminRequestModal() {
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const handleAction = (formData: FormData) => {
const data = {
title: formData.get("title") as string,
type: formData.get("type") as TerminType,
startDate: new Date(formData.get("startDate") as string).toISOString(),
endDate: new Date(formData.get("endDate") as string).toISOString(),
allDay: formData.get("allDay") === "on",
};
startTransition(async () => {
const res = await createTerminRequest(data);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Terminanfrage wurde erfolgreich gestellt!");
setOpen(false);
}
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="gap-2">
<CalendarPlus className="h-4 w-4" />
Termin anfragen
</Button>
</DialogTrigger>
<DialogContent>
<form action={handleAction}>
<DialogHeader>
<DialogTitle>Termin anfragen</DialogTitle>
<DialogDescription>
Frage einen privaten Termin (z.B. Kindergeburtstag) in den Räumlichkeiten der Kita an.
Die Anfrage muss von einem Admin bestätigt werden.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="title">Titel</Label>
<Input id="title" name="title" required placeholder="z.B. Geburtstag von Mia" />
</div>
<div className="grid gap-2">
<Label htmlFor="type">Kategorie</Label>
<select
id="type"
name="type"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
required
>
<option value={TerminType.KITA_FEST}>Kita-Fest</option>
<option value={TerminType.SCHLIESSTAG}>Schließtag</option>
<option value={TerminType.TEAMTAG}>Teamtag (Kita geschlossen)</option>
<option value={TerminType.MITMACH_TAG}>Mitmach-Tag</option>
<option value={TerminType.ELTERNABEND}>Elternabend</option>
<option value={TerminType.MITGLIEDERVERSAMMLUNG}>Mitgliederversammlung</option>
<option value={TerminType.ELTERNCAFE}>Elterncafe</option>
<option value={TerminType.GEBURTSTAG_INTERN}>Geburtstag (Intern)</option>
<option value={TerminType.GEBURTSTAG_EXTERN}>Raumanfrage (Extern)</option>
<option value={TerminType.SONSTIGES}>Sonstiges</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="startDate">Start</Label>
<Input id="startDate" name="startDate" type="datetime-local" required />
</div>
<div className="grid gap-2">
<Label htmlFor="endDate">Ende</Label>
<Input id="endDate" name="endDate" type="datetime-local" required />
</div>
</div>
<div className="flex items-center gap-2 mt-2">
<input type="checkbox" id="allDay" name="allDay" className="rounded border-gray-300" />
<Label htmlFor="allDay" className="font-normal cursor-pointer">
Ganztägiger Termin
</Label>
</div>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Wird gesendet..." : "Anfrage stellen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+235
View File
@@ -0,0 +1,235 @@
"use server";
import { revalidatePath } from "next/cache";
import { TerminStatus, TerminType, UserRole } from "@prisma/client";
import { z } from "zod";
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
// =====================================================================
// /dashboard/kalender · Server Actions
// =====================================================================
const terminSchema = z.object({
title: z.string().min(2, "Titel muss mindestens 2 Zeichen lang sein"),
description: z.string().optional(),
type: z.nativeEnum(TerminType),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
allDay: z.boolean().default(false),
});
export async function createTerminRequest(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = terminSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const data = parsed.data;
try {
await prisma.termin.create({
data: {
kitaId: session.user.kitaId,
createdById: session.user.id,
title: data.title,
description: data.description,
type: data.type,
startDate: new Date(data.startDate),
endDate: new Date(data.endDate),
allDay: data.allDay,
status: TerminStatus.PENDING,
},
});
revalidatePath("/dashboard/kalender");
return { success: true };
} catch (error) {
console.error("Fehler beim Erstellen der Terminanfrage:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
export async function createTerminAdmin(rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const parsed = terminSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const data = parsed.data;
try {
await prisma.termin.create({
data: {
kitaId: session.user.kitaId,
createdById: session.user.id,
title: data.title,
description: data.description,
type: data.type,
startDate: new Date(data.startDate),
endDate: new Date(data.endDate),
allDay: data.allDay,
status: TerminStatus.CONFIRMED,
approvedById: session.user.id,
approvedAt: new Date(),
},
});
revalidatePath("/dashboard/kalender");
return { success: true };
} catch (error) {
console.error("Fehler beim Erstellen des Termins:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
export async function approveTermin(terminId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
try {
await prisma.termin.update({
where: {
id: terminId,
kitaId: session.user.kitaId,
},
data: {
status: TerminStatus.CONFIRMED,
approvedById: session.user.id,
approvedAt: new Date(),
},
});
revalidatePath("/dashboard/kalender");
return { success: true };
} catch (error) {
console.error("Fehler beim Bestätigen des Termins:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
export async function rejectTermin(terminId: string, reason?: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
try {
await prisma.termin.update({
where: {
id: terminId,
kitaId: session.user.kitaId,
},
data: {
status: TerminStatus.REJECTED,
rejectionReason: reason,
approvedById: session.user.id,
approvedAt: new Date(),
},
});
revalidatePath("/dashboard/kalender");
return { success: true };
} catch (error) {
console.error("Fehler beim Ablehnen des Termins:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
export async function toggleMitbringselList(terminId: string, enabled: boolean) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
try {
await prisma.termin.update({
where: {
id: terminId,
kitaId: session.user.kitaId,
},
data: {
mitbringselListEnabled: enabled,
},
});
revalidatePath("/dashboard/kalender");
return { success: true };
} catch (error) {
console.error("Fehler beim Umschalten der Mitbring-Liste:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
const mitbringselSchema = z.object({
terminId: z.string(),
content: z.string().min(1, "Eintrag darf nicht leer sein"),
});
export async function addMitbringsel(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = mitbringselSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const { terminId, content } = parsed.data;
try {
// Verify termin is active and list is enabled
const termin = await prisma.termin.findUnique({
where: { id: terminId, kitaId: session.user.kitaId },
});
if (!termin || termin.status !== TerminStatus.CONFIRMED || !termin.mitbringselListEnabled) {
return { error: "Mitbring-Liste ist für diesen Termin nicht aktiv." };
}
await prisma.mitbringselItem.create({
data: {
kitaId: session.user.kitaId,
terminId: terminId,
userId: session.user.id,
content: content,
},
});
revalidatePath("/dashboard/kalender");
return { success: true };
} catch (error) {
console.error("Fehler beim Hinzufügen des Eintrags:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
export async function deleteMitbringsel(itemId: string) {
const session = await requireKitaSession();
try {
const item = await prisma.mitbringselItem.findUnique({
where: { id: itemId, kitaId: session.user.kitaId },
});
if (!item) {
return { error: "Eintrag nicht gefunden." };
}
// Admins/Koords can delete any item. Users can only delete their own.
if (
session.user.role !== UserRole.ADMIN &&
session.user.role !== UserRole.KOORDINATOR &&
item.userId !== session.user.id
) {
return { error: "Keine Berechtigung diesen Eintrag zu löschen." };
}
await prisma.mitbringselItem.delete({
where: { id: itemId },
});
revalidatePath("/dashboard/kalender");
return { success: true };
} catch (error) {
console.error("Fehler beim Löschen des Eintrags:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
+133
View File
@@ -0,0 +1,133 @@
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { UserRole, TerminStatus } from "@prisma/client";
import { TerminList } from "./_components/termin-list";
import { PendingAnfragen } from "./_components/pending-anfragen";
import { TerminRequestModal } from "./_components/termin-request-modal";
import { AdminTerminModal } from "./_components/admin-termin-modal";
import { CalendarDays } from "lucide-react";
export default async function KalenderPage({
searchParams,
}: {
searchParams: { tab?: string };
}) {
const session = await requireKitaSession();
const isAdmin =
session.user.role === UserRole.ADMIN || session.user.role === UserRole.KOORDINATOR;
const currentTab = searchParams.tab || "übersicht";
// Fetch confirmed events
const confirmedTermine = await prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.CONFIRMED,
},
include: {
mitbringselItems: {
include: {
user: { select: { firstName: true, lastName: true } },
},
},
},
orderBy: { startDate: "asc" },
});
// Fetch user's own pending events
const myPendingTermine = await prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.PENDING,
createdById: session.user.id,
},
orderBy: { startDate: "asc" },
});
// Combine for general view
const allUserTermine = [...confirmedTermine, ...myPendingTermine].sort(
(a, b) => a.startDate.getTime() - b.startDate.getTime()
);
// If admin, fetch all pending events
let allPendingTermine: any[] = [];
if (isAdmin) {
allPendingTermine = await prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.PENDING,
},
include: {
createdBy: { select: { firstName: true, lastName: true } },
},
orderBy: { createdAt: "desc" },
});
}
return (
<div className="flex h-full flex-col gap-6 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Terminkalender</h1>
<p className="text-muted-foreground">
Alle anstehenden Termine, Feste und Schließtage im Überblick.
</p>
</div>
<div className="flex gap-2">
<TerminRequestModal />
{isAdmin && <AdminTerminModal />}
</div>
</div>
{isAdmin ? (
<div className="flex flex-col gap-4">
<div className="flex gap-4 border-b">
<a
href="/dashboard/kalender?tab=übersicht"
className={`pb-2 text-sm font-medium transition-colors hover:text-primary ${
currentTab === "übersicht"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground"
}`}
>
Übersicht
</a>
<a
href="/dashboard/kalender?tab=anfragen"
className={`pb-2 text-sm font-medium transition-colors hover:text-primary flex items-center gap-2 ${
currentTab === "anfragen"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground"
}`}
>
Ausstehende Anfragen
{allPendingTermine.length > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
{allPendingTermine.length}
</span>
)}
</a>
</div>
<div className="pt-2">
{currentTab === "übersicht" ? (
<TerminList
termine={allUserTermine}
userId={session.user.id}
isAdmin={isAdmin}
/>
) : (
<PendingAnfragen termine={allPendingTermine} />
)}
</div>
</div>
) : (
<TerminList
termine={allUserTermine}
userId={session.user.id}
isAdmin={isAdmin}
/>
)}
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
import Link from "next/link";
import { Baby, LogOut } from "lucide-react";
import { auth, signOut } from "@/auth";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { SidebarNav } from "@/components/dashboard/sidebar-nav";
// =====================================================================
// Dashboard-Layout · Linke Sidebar
// ---------------------------------------------------------------------
// Server Component: liest kitaId aus der Session, holt den Kita-Namen
// aus der DB und rendert das App-Shell (Sidebar + main content area).
// Der eigentliche Seiteninhalt kommt via {children} rein.
// =====================================================================
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await requireKitaSession();
const kita = await prisma.kita.findUniqueOrThrow({
where: { id: session.user.kitaId },
select: { name: true },
});
return (
<div className="flex min-h-screen bg-muted/30">
{/* ── Sidebar ───────────────────────────────────────────────────── */}
<aside className="flex w-60 shrink-0 flex-col border-r bg-background">
{/* Logo / Kita-Name */}
<div className="flex h-16 items-center gap-2.5 border-b px-5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Baby className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-none">
{kita.name}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">Kita-Planer</p>
</div>
</div>
{/* Navigation */}
<div className="flex-1 overflow-y-auto py-4">
<SidebarNav role={session.user.role as any} />
</div>
{/* Footer: User-Info + Abmelden */}
<div className="border-t p-3">
<Separator className="mb-3" />
<div className="mb-2 px-3">
<p className="truncate text-xs font-medium">
{session.user.name ?? session.user.email}
</p>
<p className="truncate text-xs text-muted-foreground">
{session.user.role}
</p>
</div>
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/" });
}}
>
<Button
type="submit"
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground"
>
<LogOut className="h-4 w-4" />
Abmelden
</Button>
</form>
</div>
</aside>
{/* ── Main Content ──────────────────────────────────────────────── */}
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
"use server";
import { revalidatePath } from "next/cache";
import { UserRole } from "@prisma/client";
import { z } from "zod";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData } from "@/lib/date-utils";
// =====================================================================
// /dashboard/notdienst · Server Actions
// ---------------------------------------------------------------------
// Speichert die gebuchten Termine der Eltern im Zielmonat.
// Die Termine werden Round-Robin auf die Kinder aufgeteilt.
// =====================================================================
const payloadSchema = z.object({
childrenIds: z.array(z.string()).min(1),
dates: z.array(z.string().datetime()), // ISO strings
});
export async function saveNotdienstAvailabilities(payloadRaw: {
childrenIds: string[];
dates: string[];
}) {
const session = await requireRole([
UserRole.ELTERN,
UserRole.ADMIN,
UserRole.KOORDINATOR,
]);
const parsed = payloadSchema.safeParse(payloadRaw);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const { childrenIds, dates } = parsed.data;
// ── 1. Sperrfrist prüfen ───────────────────────────────────────────
const { targetYear, targetMonth, isLocked } = getTargetMonthData();
if (isLocked) {
return { error: "Die Planung für diesen Monat ist bereits abgeschlossen." };
}
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
// ── 2. Validierung (Soll-Tage) ─────────────────────────────────────
const kita = await prisma.kita.findUniqueOrThrow({
where: { id: kitaId },
select: { notdienstMinPerChildPerMonth: true },
});
const requiredTotal = childrenIds.length * kita.notdienstMinPerChildPerMonth;
if (dates.length < requiredTotal) {
return {
error: `Bitte wähle mindestens ${requiredTotal} Tage aus.`,
};
}
// Sicherstellen, dass alle Kinder zum angemeldeten User gehören und zur kitaId passen
const validChildren = await prisma.child.findMany({
where: {
id: { in: childrenIds },
kitaId,
parentLinks: { some: { userId: session.user.id } },
},
select: { id: true },
});
if (validChildren.length !== childrenIds.length) {
return { error: "Ungültige Kinder-Zuordnung erkannt." };
}
// Termine in Date-Objekte wandeln und sortieren
const parsedDates = dates
.map((d) => new Date(d))
.sort((a, b) => a.getTime() - b.getTime());
// Round-Robin Zuweisung
const inserts: {
kitaId: string;
childId: string;
userId: string;
date: Date;
}[] = [];
for (let i = 0; i < parsedDates.length; i++) {
const childId = validChildren[i % validChildren.length].id;
inserts.push({
kitaId,
childId,
userId: session.user.id,
date: parsedDates[i],
});
}
// ── 3. Datenbank-Transaktion ───────────────────────────────────────
try {
await prisma.$transaction(async (tx) => {
// a) Alle *eigenen* bestehenden Einträge im Zielmonat für diese Kinder löschen
await tx.notdienstAvailability.deleteMany({
where: {
kitaId,
userId: session.user.id,
childId: { in: childrenIds },
date: {
gte: new Date(targetYear, targetMonth - 1, 1),
lt: new Date(targetYear, targetMonth, 1),
},
},
});
// b) Neue Einträge setzen
if (inserts.length > 0) {
await tx.notdienstAvailability.createMany({
data: inserts,
skipDuplicates: true,
});
}
});
revalidatePath("/dashboard/notdienst");
return { success: true };
} catch (error) {
console.error("Fehler beim Speichern der Notdienste:", error);
return { error: "Ein Fehler ist aufgetreten beim Speichern der Termine." };
}
}
@@ -0,0 +1,184 @@
"use client";
import { useState, useTransition } from "react";
import { Info, Loader2, CheckCircle2 } from "lucide-react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { getWorkingDaysOfMonth } from "@/lib/date-utils";
import { Button } from "@/components/ui/button";
import { saveNotdienstAvailabilities } from "./actions";
// =====================================================================
// NotdienstForm · Client Component
// ---------------------------------------------------------------------
// Erlaubt das Auswählen von N Tagen aus der Liste der Werktage des
// Zielmonats. Speichert als Array von Datum-Strings.
// =====================================================================
interface NotdienstFormProps {
targetYear: number;
targetMonth: number;
isLocked: boolean;
requiredDaysTotal: number;
initialSelectedDates: string[]; // ISO Strings
childrenIds: string[];
}
export function NotdienstForm({
targetYear,
targetMonth,
isLocked,
requiredDaysTotal,
initialSelectedDates,
childrenIds,
}: NotdienstFormProps) {
// Lokaler State für die Auswahl. Ein Datum liegt immer als ISO-String vor (z.B. "2026-06-01T00:00:00.000Z")
const [selectedDates, setSelectedDates] = useState<Set<string>>(
new Set(initialSelectedDates),
);
const [isPending, startTransition] = useTransition();
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
const selectedCount = selectedDates.size;
const isFulfilled = selectedCount >= requiredDaysTotal;
function toggleDate(dateStr: string) {
if (isLocked || isPending) return;
const newSet = new Set(selectedDates);
if (newSet.has(dateStr)) {
newSet.delete(dateStr);
} else {
newSet.add(dateStr);
}
setSelectedDates(newSet);
}
function handleSave() {
if (isLocked) return;
if (!isFulfilled) {
toast.error(
`Bitte wähle noch ${requiredDaysTotal - selectedCount} weitere Termine aus.`,
);
return;
}
startTransition(async () => {
const payload = {
dates: Array.from(selectedDates),
childrenIds,
};
const result = await saveNotdienstAvailabilities(payload);
if (result?.error) {
toast.error(result.error);
} else {
toast.success("Verfügbarkeiten erfolgreich gespeichert.");
}
});
}
return (
<div className="space-y-6">
{/* Fortschritt / Sperr-Hinweis */}
<div
className={cn(
"flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between",
isLocked ? "bg-destructive/5" : "bg-card",
)}
>
<div className="flex items-start gap-3">
{isLocked ? (
<Info className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
) : isFulfilled ? (
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-emerald-500" />
) : (
<Info className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
)}
<div>
<h3 className="font-medium">
{isLocked
? "Planung abgeschlossen"
: "Deine ausgewählten Termine"}
</h3>
<p className="text-sm text-muted-foreground">
{isLocked
? "Die Frist für diesen Monat ist abgelaufen. Änderungen sind nicht mehr möglich."
: `Du hast ${selectedCount} von ${requiredDaysTotal} benötigten Tagen markiert.`}
</p>
</div>
</div>
<Button
onClick={handleSave}
disabled={isLocked || isPending || !isFulfilled}
className="shrink-0"
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{isLocked ? "Gesperrt" : "Speichern"}
</Button>
</div>
{/* Datumsliste */}
<div className="rounded-lg border bg-card">
<div className="p-4 border-b bg-muted/30">
<p className="text-sm font-medium">Werktage im {format(new Date(targetYear, targetMonth - 1, 1), "MMMM", { locale: de })}</p>
</div>
<div className="divide-y">
{workingDays.map((day) => {
const dateStr = day.toISOString();
const isSelected = selectedDates.has(dateStr);
const isToday = day.getTime() === new Date().setHours(0, 0, 0, 0);
return (
<button
key={dateStr}
type="button"
disabled={isLocked || isPending}
onClick={() => toggleDate(dateStr)}
className={cn(
"flex w-full items-center justify-between px-4 py-3 text-left transition-colors",
!isLocked && !isPending && "hover:bg-muted/50",
isSelected && "bg-primary/5 hover:bg-primary/10",
(isLocked || isPending) && "cursor-not-allowed opacity-70",
)}
>
<div className="flex items-center gap-3">
{/* Custom Checkbox Indicator */}
<div
className={cn(
"flex h-5 w-5 items-center justify-center rounded border",
isSelected
? "border-primary bg-primary text-primary-foreground"
: "border-input bg-background",
)}
>
{isSelected && <CheckCircle2 className="h-3.5 w-3.5" />}
</div>
<div>
<span className={cn("font-medium", isSelected && "text-primary")}>
{format(day, "EEEE, dd. MMMM", { locale: de })}
</span>
{isToday && (
<span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
Heute
</span>
)}
</div>
</div>
</button>
);
})}
</div>
</div>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { UserRole } from "@prisma/client";
import { Info, Calendar } from "lucide-react";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData } from "@/lib/date-utils";
import { NotdienstForm } from "./notdienst-form";
export const metadata = { title: "Notdienst · Kita-Planer" };
export default async function NotdienstPage() {
// ELTERN können eintragen; Admins/Koordinatoren haben hier eigentlich
// andere Ansichten (Plan-Generierung), aber wir lassen sie zur Demonstration
// ebenfalls durch, falls sie selbst Kinder haben.
const session = await requireRole([
UserRole.ELTERN,
UserRole.ADMIN,
UserRole.KOORDINATOR,
]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return <div className="p-8">Kein Mandant zugeordnet.</div>;
}
// Kita-Einstellungen laden
const kita = await prisma.kita.findUniqueOrThrow({
where: { id: kitaId },
select: { notdienstMinPerChildPerMonth: true },
});
// Nur eigene, aktive Kinder laden
const children = await prisma.child.findMany({
where: {
kitaId,
active: true,
parentLinks: { some: { userId: session.user.id } },
},
select: { id: true, firstName: true },
});
const targetData = getTargetMonthData();
const { targetYear, targetMonth, monthName, isLocked } = targetData;
const requiredDaysTotal = children.length * kita.notdienstMinPerChildPerMonth;
// Bisherige Einträge für den Zielmonat laden
// Wir filtern bewusst nach den eigenen Kindern und dem User
const availabilities = await prisma.notdienstAvailability.findMany({
where: {
kitaId,
userId: session.user.id,
date: {
gte: new Date(targetYear, targetMonth - 1, 1),
lt: new Date(targetYear, targetMonth, 1),
},
},
select: { date: true },
});
const selectedDates = availabilities.map((a) => a.date.toISOString());
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-8">
<div className="mb-6 flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Calendar className="h-6 w-6 text-primary" />
Notdienst planen
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Gib hier an, an welchen Tagen du im Notdienst unterstützen kannst.
</p>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<span className="rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary">
Zielmonat: {monthName}
</span>
{isLocked && (
<span className="text-xs font-medium text-destructive flex items-center gap-1">
<Info className="h-3 w-3" />
Eingabefrist abgelaufen
</span>
)}
</div>
</div>
{children.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
Deinem Account sind noch keine Kinder zugeordnet.
</div>
) : (
<NotdienstForm
targetYear={targetYear}
targetMonth={targetMonth}
isLocked={isLocked}
requiredDaysTotal={requiredDaysTotal}
initialSelectedDates={selectedDates}
childrenIds={children.map((c) => c.id)}
/>
)}
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
"use server";
import { revalidatePath } from "next/cache";
import { NotdienstPlanStatus, UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
// =====================================================================
// /dashboard/notdienst/plan · Server Actions
// =====================================================================
export async function generatePlanAction() {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
const { targetYear, targetMonth } = getTargetMonthData();
// Prüfen, ob schon ein Plan existiert
const existingPlan = await prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
},
});
if (existingPlan) {
return { error: "Ein Plan für diesen Monat existiert bereits." };
}
// 1. Alle Verfügbarkeiten der Eltern für diesen Monat holen
const availabilities = await prisma.notdienstAvailability.findMany({
where: {
kitaId,
date: {
gte: new Date(targetYear, targetMonth - 1, 1),
lt: new Date(targetYear, targetMonth, 1),
},
},
include: { child: true, user: true },
});
// 2. Werktage holen
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
// 3. Algorithmus: Wir verteilen die Slots
// Counter für "Gerechtigkeit": wie oft wurde diese Familie schon eingeteilt?
const familyUsageCount: Record<string, number> = {};
const assignmentsToCreate: { childId: string; date: Date }[] = [];
for (const day of workingDays) {
// Alle Verfügbarkeiten für diesen spezifischen Tag
const availForDay = availabilities.filter(
(a) => a.date.getTime() === day.getTime(),
);
if (availForDay.length === 0) {
// Niemand verfügbar -> Bleibt leer ("Unbesetzt")
continue;
}
// Finde die Familie, die am wenigsten oft dran war
// Zufall einbauen bei Gleichstand
const sorted = availForDay.sort((a, b) => {
const countA = familyUsageCount[a.userId] || 0;
const countB = familyUsageCount[b.userId] || 0;
if (countA === countB) {
return Math.random() - 0.5; // Zufallsmischung
}
return countA - countB;
});
const selected = sorted[0];
assignmentsToCreate.push({
childId: selected.childId,
date: day,
});
// Usage-Counter erhöhen
familyUsageCount[selected.userId] =
(familyUsageCount[selected.userId] || 0) + 1;
}
// 4. In der DB speichern
try {
await prisma.$transaction(async (tx) => {
const plan = await tx.notdienstPlan.create({
data: {
kitaId,
year: targetYear,
month: targetMonth,
status: NotdienstPlanStatus.DRAFT,
createdById: session.user.id,
},
});
if (assignmentsToCreate.length > 0) {
await tx.notdienstAssignment.createMany({
data: assignmentsToCreate.map((a) => ({
kitaId,
planId: plan.id,
childId: a.childId,
date: a.date,
})),
});
}
});
revalidatePath("/dashboard/notdienst/plan");
return { success: true };
} catch (error) {
console.error(error);
return { error: "Fehler bei der Generierung des Plans." };
}
}
export async function publishPlanAction(planId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
await prisma.notdienstPlan.update({
where: { id: planId, kitaId },
data: {
status: NotdienstPlanStatus.PUBLISHED,
publishedAt: new Date(),
},
});
revalidatePath("/dashboard/notdienst/plan");
revalidatePath("/dashboard/notdienst");
return { success: true };
}
export async function updateAssignmentAction(
planId: string,
date: string,
newChildId: string | null,
) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
const parsedDate = new Date(date);
try {
await prisma.$transaction(async (tx) => {
// Prüfen, ob Plan noch DRAFT ist
const plan = await tx.notdienstPlan.findUnique({
where: { id: planId, kitaId },
});
if (!plan || plan.status !== NotdienstPlanStatus.DRAFT) {
throw new Error("Plan kann nicht mehr bearbeitet werden.");
}
// Altes Assignment für diesen Tag löschen
await tx.notdienstAssignment.deleteMany({
where: { planId, date: parsedDate },
});
// Neues anlegen, falls ausgewählt
if (newChildId) {
await tx.notdienstAssignment.create({
data: {
kitaId,
planId,
childId: newChildId,
date: parsedDate,
},
});
}
});
revalidatePath("/dashboard/notdienst/plan");
return { success: true };
} catch (error: any) {
return { error: error.message || "Update fehlgeschlagen." };
}
}
+93
View File
@@ -0,0 +1,93 @@
import { UserRole } from "@prisma/client";
import { Settings2 } from "lucide-react";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { PlanView } from "./plan-view";
export const metadata = { title: "Plan-Generierung · Kita-Planer" };
export default async function PlanungsZentralePage() {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
const { targetYear, targetMonth, monthName } = getTargetMonthData();
// Aktuellen Plan suchen
const plan = await prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
},
include: {
assignments: {
include: { child: { include: { parentLinks: { include: { user: true } } } } },
},
},
});
// Alle Werktage
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
// aber für MVP reichen alle aktiven Kinder.
const allChildrenRaw = await prisma.child.findMany({
where: { kitaId, active: true },
include: {
parentLinks: { include: { user: true } },
},
orderBy: { lastName: "asc" },
});
const allChildren = allChildrenRaw.map((c) => {
// Einfachheit halber nehmen wir den ersten verknüpften Elternteil zur Anzeige
const parent = c.parentLinks[0]?.user;
const parentName = parent ? `${parent.firstName} ${parent.lastName}` : "Unbekannt";
return {
id: c.id,
name: `${c.firstName} ${c.lastName}`,
parentName,
};
});
// Wir mappen die Assignments auf die Werktage
const daysWithAssignments = workingDays.map((day) => {
const assignment = plan?.assignments.find(
(a) => a.date.getTime() === day.getTime()
);
return {
date: day.toISOString(),
assignmentId: assignment?.id || null,
childId: assignment?.childId || null,
};
});
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-8">
<div className="mb-6 flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Settings2 className="h-6 w-6 text-primary" />
Notdienst-Planung
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Koordiniere und generiere den Notdienst-Plan.
</p>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary">
Zielmonat: {monthName}
</span>
</div>
</div>
<PlanView
planId={plan?.id}
status={plan?.status}
days={daysWithAssignments}
allChildren={allChildren}
/>
</div>
);
}
@@ -0,0 +1,186 @@
"use client";
import { useState, useTransition } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { NotdienstPlanStatus } from "@prisma/client";
import { Loader2, Wand2, CheckSquare } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
generatePlanAction,
publishPlanAction,
updateAssignmentAction,
} from "./actions";
interface PlanViewProps {
planId?: string;
status?: NotdienstPlanStatus;
days: {
date: string; // ISO
assignmentId: string | null;
childId: string | null;
}[];
allChildren: {
id: string;
name: string;
parentName: string;
}[];
}
export function PlanView({ planId, status, days, allChildren }: PlanViewProps) {
const [isPending, startTransition] = useTransition();
const handleGenerate = () => {
startTransition(async () => {
const result = await generatePlanAction();
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
toast.success("Plan erfolgreich generiert.");
}
});
};
const handlePublish = () => {
if (!planId) return;
startTransition(async () => {
const result = await publishPlanAction(planId);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
toast.success("Plan veröffentlicht! Er ist nun fix.");
}
});
};
const handleAssignmentChange = (date: string, newChildId: string) => {
if (!planId || status !== "DRAFT") return;
const valueToSet = newChildId === "empty" ? null : newChildId;
startTransition(async () => {
const result = await updateAssignmentAction(planId, date, valueToSet);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
toast.success("Zuteilung aktualisiert.");
}
});
};
if (!planId) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-card p-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<Wand2 className="h-6 w-6" />
</div>
<h3 className="mb-1 text-lg font-semibold">Noch kein Plan vorhanden</h3>
<p className="mb-6 max-w-sm text-sm text-muted-foreground">
Generiere den Plan basierend auf den Verfügbarkeiten der Eltern
automatisch.
</p>
<Button onClick={handleGenerate} disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Plan automatisch generieren
</Button>
</div>
);
}
const isDraft = status === "DRAFT";
return (
<div className="space-y-6">
<div className="flex items-center justify-between rounded-lg border bg-card p-4">
<div className="flex items-center gap-3">
<Badge variant={isDraft ? "warning" : "success"}>
{isDraft ? "Entwurf" : "Veröffentlicht"}
</Badge>
<span className="text-sm text-muted-foreground">
{isDraft
? "Du kannst die Liste jetzt manuell anpassen."
: "Dieser Plan ist verbindlich."}
</span>
</div>
{isDraft && (
<Button onClick={handlePublish} disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<CheckSquare className="mr-2 h-4 w-4" />
Plan veröffentlichen
</Button>
)}
</div>
<div className="rounded-lg border bg-card">
<div className="grid grid-cols-[1fr_2fr] border-b bg-muted/30 p-4 text-sm font-medium">
<div>Datum</div>
<div>Eingeteilte Familie</div>
</div>
<div className="divide-y">
{days.map((day) => {
const dateObj = new Date(day.date);
const isUnbesetzt = !day.childId;
return (
<div
key={day.date}
className="grid grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-muted/10"
>
<div className="font-medium">
{format(dateObj, "EE, dd.MM.", { locale: de })}
</div>
<div>
{isDraft ? (
<select
className="w-full max-w-sm rounded-md border border-input bg-background px-3 py-1.5 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={day.childId || "empty"}
disabled={isPending}
onChange={(e) =>
handleAssignmentChange(day.date, e.target.value)
}
>
<option value="empty" className="text-destructive">
-- Unbesetzt --
</option>
{allChildren.map((c) => (
<option key={c.id} value={c.id}>
{c.parentName} ({c.name})
</option>
))}
</select>
) : (
<div>
{isUnbesetzt ? (
<span className="text-sm font-medium text-destructive">
Unbesetzt!
</span>
) : (
<span className="text-sm">
{
allChildren.find((c) => c.id === day.childId)
?.parentName
}{" "}
<span className="text-muted-foreground">
(
{
allChildren.find((c) => c.id === day.childId)
?.name
}
)
</span>
</span>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
+210
View File
@@ -0,0 +1,210 @@
import Link from "next/link";
import { Users, CalendarCheck2, ShieldCheck, AlertTriangle } from "lucide-react";
import { UserRole, NotdienstPlanStatus } from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { AlertButton } from "./alert-button";
export const metadata = { title: "Übersicht · Kita-Planer" };
export default async function DashboardPage() {
const session = await requireKitaSession();
const kita = await prisma.kita.findUniqueOrThrow({
where: { id: session.user.kitaId },
select: {
name: true,
notdienstModuleEnabled: true,
terminModuleEnabled: true,
adressbuchModuleEnabled: true,
},
});
// Schnelle Statistiken für die Übersicht
const [familyCount, childCount] = await Promise.all([
prisma.user.count({
where: { kitaId: session.user.kitaId, role: UserRole.ELTERN },
}),
prisma.child.count({
where: { kitaId: session.user.kitaId },
}),
]);
const isAdmin =
session.user.role === UserRole.ADMIN ||
session.user.role === UserRole.SUPERADMIN;
return (
<div className="px-8 py-8">
<div className="mb-8">
<h1 className="text-2xl font-semibold tracking-tight">
Willkommen, {session.user.name?.split(" ")[0] ?? "zusammen"}!
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Hier ist die Übersicht für <strong>{kita.name}</strong>.
</p>
</div>
{/* Heutiger Notdienst (Nur für Admins/Koordinatoren) */}
{isAdmin && (
<div className="mb-8">
<TodaysNotdienstCard kitaId={session.user.kitaId!} />
</div>
)}
{/* Statistik-Kacheln */}
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
label="Elternteile"
value={familyCount}
description="registrierte Familien"
icon={<Users className="h-5 w-5 text-muted-foreground" />}
/>
<StatCard
label="Kinder"
value={childCount}
description="in der Einrichtung"
icon={<ShieldCheck className="h-5 w-5 text-muted-foreground" />}
/>
</div>
{/* Schnell-Aktionen für Admins */}
{isAdmin && (
<Card>
<CardHeader>
<CardTitle className="text-base">Schnellstart</CardTitle>
<CardDescription>
Die nächsten Schritte, um deine Kita einzurichten.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-3">
<Button asChild size="sm">
<Link href="/dashboard/families">
<Users className="mr-2 h-4 w-4" />
Familien verwalten
</Link>
</Button>
{kita.terminModuleEnabled && (
<Button asChild size="sm" variant="outline">
<Link href="/dashboard/termine">
<CalendarCheck2 className="mr-2 h-4 w-4" />
Termine (bald verfügbar)
</Link>
</Button>
)}
</CardContent>
</Card>
)}
</div>
);
}
function StatCard({
label,
value,
description,
icon,
}: {
label: string;
value: number;
description: string;
icon: React.ReactNode;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{label}
</CardTitle>
{icon}
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{value}</div>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}
async function TodaysNotdienstCard({ kitaId }: { kitaId: string }) {
const today = new Date();
today.setHours(0, 0, 0, 0);
// Wir suchen das Assignment für heute, aber nur wenn der Plan PUBLISHED ist
const assignment = await prisma.notdienstAssignment.findFirst({
where: {
kitaId,
date: today,
plan: { status: NotdienstPlanStatus.PUBLISHED },
},
include: {
child: { include: { parentLinks: { include: { user: true } } } },
alerts: true, // Um zu sehen, ob schon ein Alarm ausgelöst wurde
},
});
if (!assignment) return null;
const parent = assignment.child.parentLinks[0]?.user;
const existingAlert = assignment.alerts[0]; // Kann PENDING, CONFIRMED oder CANCELLED sein
return (
<Card className="border-primary/20 bg-primary/5">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-primary" />
Heutiger Notdienst
</CardTitle>
<CardDescription>
Laut dem veröffentlichten Plan ist heute folgende Familie eingeteilt:
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium text-base">
{parent ? `${parent.firstName} ${parent.lastName}` : "Unbekannt"}
</p>
<p className="text-sm text-muted-foreground">
Kind: {assignment.child.firstName} {assignment.child.lastName}
</p>
</div>
<div className="flex items-center gap-3">
{existingAlert ? (
<Badge
variant={
existingAlert.status === "CONFIRMED"
? "success"
: existingAlert.status === "PENDING"
? "warning"
: "destructive"
}
className="px-3 py-1 text-sm"
>
{existingAlert.status === "CONFIRMED"
? "Einsatz bestätigt"
: existingAlert.status === "PENDING"
? "Wartet auf Bestätigung"
: "Abgebrochen"}
</Badge>
) : (
<AlertButton
assignmentId={assignment.id}
parentUserId={parent?.id || ""}
/>
)}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,97 @@
"use client";
import { useState, useTransition } from "react";
import { AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { signOut } from "next-auth/react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { deleteMyAccount } from "../actions";
export function DeleteAccountDialog() {
const [open, setOpen] = useState(false);
const [confirmText, setConfirmText] = useState("");
const [isPending, startTransition] = useTransition();
const isConfirmed = confirmText === "LÖSCHEN";
const handleDelete = () => {
if (!isConfirmed) return;
startTransition(async () => {
const res = await deleteMyAccount();
if (res.error) {
toast.error(res.error);
} else {
toast.success("Account wurde gelöscht. Du wirst abgemeldet...");
// Sign out client-side and redirect to landing page
await signOut({ callbackUrl: "/" });
}
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="destructive" className="w-full sm:w-auto">
<AlertTriangle className="h-4 w-4 mr-2" />
Account & Daten endgültig löschen
</Button>
</DialogTrigger>
<DialogContent className="border-destructive/30">
<DialogHeader>
<DialogTitle className="text-destructive flex items-center gap-2">
<AlertTriangle className="h-5 w-5" />
Gefahrenzone: Account löschen
</DialogTitle>
<DialogDescription className="text-base pt-2">
Diese Aktion kann <strong className="text-foreground">nicht</strong> rückgängig gemacht werden.
Alle deine personenbezogenen Daten, Verknüpfungen zu deinen Kindern, gebuchten Notdienste
und Mitbringsel-Einträge werden DSGVO-konform sofort und unwiderruflich aus der Datenbank gelöscht.
</DialogDescription>
</DialogHeader>
<div className="bg-destructive/10 text-destructive-foreground p-3 text-sm rounded-md my-2 border border-destructive/20">
Bitte tippe das Wort <strong>LÖSCHEN</strong> in das Feld unten ein, um den Vorgang zu bestätigen.
</div>
<div className="grid gap-2 py-4">
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="LÖSCHEN"
className="border-destructive/50 focus-visible:ring-destructive"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
disabled={isPending}
>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={!isConfirmed || isPending}
>
{isPending ? "Wird gelöscht..." : "Endgültig löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use server";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { signOut } from "@/auth"; // Note: this is NextAuth v5 server side signOut if needed, but we do client side
export async function deleteMyAccount() {
const session = await requireKitaSession();
try {
// Da wir onDelete: Cascade überall konfiguriert haben,
// löscht dieser eine Befehl den User, seine ChildParent-Links,
// seine MitbringselItems, seine NotdienstAvailabilities etc.
await prisma.user.delete({
where: {
id: session.user.id,
},
});
// The client component will trigger the actual NextAuth client signOut,
// but returning success indicates the DB deletion was successful.
return { success: true };
} catch (error) {
console.error("Fehler beim Löschen des Accounts:", error);
return { error: "Ein Fehler ist beim Löschen aufgetreten." };
}
}
+142
View File
@@ -0,0 +1,142 @@
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { UserCircle, Mail, Baby, Shield, CalendarHeart } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { DeleteAccountDialog } from "./_components/delete-account-dialog";
export const metadata = { title: "Mein Profil · Kita-Planer" };
export default async function ProfilPage() {
const session = await requireKitaSession();
const user = await prisma.user.findUniqueOrThrow({
where: { id: session.user.id },
include: {
childLinks: {
include: { child: true },
},
dutyAssignments: {
include: { duty: true },
},
},
});
return (
<div className="flex h-full flex-col gap-6 p-6 max-w-4xl mx-auto w-full">
<div>
<h1 className="text-2xl font-bold tracking-tight">Mein Profil</h1>
<p className="text-muted-foreground">
Deine persönlichen Daten, verknüpfte Kinder und DSGVO-Einstellungen.
</p>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* Persönliche Daten */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserCircle className="h-5 w-5" />
Persönliche Daten
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div>
<div className="text-sm text-muted-foreground">Name</div>
<div className="font-medium text-lg">
{user.firstName} {user.lastName}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground flex items-center gap-1">
<Mail className="h-3.5 w-3.5" /> E-Mail
</div>
<div className="font-medium">{user.email}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Rolle im Verein</div>
<Badge variant={user.role === "ELTERN" ? "secondary" : "default"} className="mt-1">
{user.role}
</Badge>
</div>
<div className="pt-2 border-t mt-2">
<div className="text-sm text-muted-foreground flex items-center gap-1">
<CalendarHeart className="h-3.5 w-3.5" /> Mitglied seit
</div>
<div className="text-sm">
{format(user.createdAt, "PPP", { locale: de })}
</div>
</div>
</CardContent>
</Card>
<div className="flex flex-col gap-6">
{/* Kinder */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Baby className="h-5 w-5" />
Meine Kinder
</CardTitle>
</CardHeader>
<CardContent>
{user.childLinks.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Kinder verknüpft.</p>
) : (
<div className="flex flex-col gap-2">
{user.childLinks.map((link) => (
<div key={link.child.id} className="flex items-center justify-between p-2 rounded-md bg-muted/50">
<span className="font-medium">
{link.child.firstName} {link.child.lastName}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Ämter */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Meine Ämter & Dienste
</CardTitle>
</CardHeader>
<CardContent>
{user.dutyAssignments.length === 0 ? (
<p className="text-sm text-muted-foreground">Du hast aktuell keine festen Ämter übernommen.</p>
) : (
<div className="flex flex-wrap gap-2">
{user.dutyAssignments.map((assignment) => (
<Badge key={assignment.id} variant="default" className="text-sm py-1 px-3">
{assignment.duty.name}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Danger Zone */}
<Card className="border-destructive/20 mt-8">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>
Hier kannst du dein Profil und alle zugehörigen Daten DSGVO-konform löschen.
</CardDescription>
</CardHeader>
<CardContent>
<DeleteAccountDialog />
</CardContent>
</Card>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import Link from "next/link";
import { ShieldX } from "lucide-react";
import { Button } from "@/components/ui/button";
export const metadata = { title: "Zugriff verweigert · Kita-Planer" };
export default function ForbiddenPage() {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/30 px-4 text-center">
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<ShieldX className="h-8 w-8" />
</div>
<h1 className="text-2xl font-semibold tracking-tight">
Zugriff verweigert
</h1>
<p className="mt-3 max-w-sm text-sm text-muted-foreground">
Du hast keine Berechtigung, diese Seite aufzurufen. Falls du glaubst,
dass dies ein Fehler ist, wende dich an deinen Kita-Administrator.
</p>
<div className="mt-8 flex gap-3">
<Button asChild>
<Link href="/dashboard">Zum Dashboard</Link>
</Button>
<Button asChild variant="outline">
<Link href="/login">Anmelden</Link>
</Button>
</div>
</div>
);
}
+71 -11
View File
@@ -1,26 +1,86 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 0.1);
--input: oklch(1 0 0 / 0.15);
--ring: oklch(0.556 0 0);
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
@layer base {
* {
border-color: var(--border);
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-sans), system-ui, sans-serif;
}
}
+135
View File
@@ -0,0 +1,135 @@
"use server";
import { hash } from "bcryptjs";
import { z } from "zod";
import { signIn } from "@/auth";
import { prisma } from "@/lib/prisma";
// =====================================================================
// /invite/[token] · Server Action
// ---------------------------------------------------------------------
// acceptInviteAction:
// 1. Token erneut gegen DB prüfen (kein Trust auf Client-State)
// 2. Passwort hashen + User-Daten setzen (DSGVO-Timestamps)
// 3. Token löschen (kein Replay möglich)
// 4. Direkt einloggen + Redirect auf /dashboard
// =====================================================================
const inviteSchema = z
.object({
token: z.string().min(1),
password: z.string().min(8, "Mindestens 8 Zeichen.").max(72, "Maximal 72 Zeichen."),
confirmPassword: z.string().min(1, "Passwort-Bestätigung fehlt."),
acceptPrivacyPolicy: z.literal("on", {
error: "Bitte die Datenschutzerklärung akzeptieren.",
}),
directoryOptIn: z.enum(["on", "off"]).optional(),
})
.refine((d) => d.password === d.confirmPassword, {
message: "Passwörter stimmen nicht überein.",
path: ["confirmPassword"],
});
const PRIVACY_POLICY_VERSION = "2026-05-01";
export type InviteState = {
errors?: {
password?: string[];
confirmPassword?: string[];
acceptPrivacyPolicy?: string[];
directoryOptIn?: string[];
_form?: string[];
};
};
export async function acceptInviteAction(
_prev: InviteState,
formData: FormData,
): Promise<InviteState> {
const parsed = inviteSchema.safeParse({
token: formData.get("token"),
password: formData.get("password"),
confirmPassword: formData.get("confirmPassword"),
acceptPrivacyPolicy: formData.get("acceptPrivacyPolicy"),
directoryOptIn: formData.get("directoryOptIn") ?? "off",
});
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
const { token, password, directoryOptIn } = parsed.data;
const now = new Date();
// ── 1. Token erneut DB-seitig validieren ──────────────────────────
const verificationToken = await prisma.verificationToken.findUnique({
where: { token },
});
if (!verificationToken) {
return { errors: { _form: ["Einladungslink ist ungültig."] } };
}
if (verificationToken.expires < now) {
return {
errors: {
_form: [
"Dieser Einladungslink ist abgelaufen. Bitte wende dich an deinen Administrator.",
],
},
};
}
const userId = verificationToken.identifier;
// ── 2. User validieren (existiert und hat noch kein Passwort) ──────
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true, passwordHash: true },
});
if (!user) {
return { errors: { _form: ["Benutzer nicht gefunden."] } };
}
if (user.passwordHash !== "") {
// Invite wurde bereits eingelöst
return {
errors: {
_form: [
"Dieser Einladungslink wurde bereits verwendet. Bitte melde dich an.",
],
},
};
}
// ── 3. Passwort hashen ─────────────────────────────────────────────
const passwordHash = await hash(password, 12);
// ── 4. User updaten + Token löschen (atomar) ──────────────────────
await prisma.$transaction([
prisma.user.update({
where: { id: userId },
data: {
passwordHash,
privacyPolicyAcceptedAt: now,
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
directoryOptInAt: directoryOptIn === "on" ? now : null,
emailVerifiedAt: now,
lastLoginAt: now,
},
}),
prisma.verificationToken.delete({
where: { token },
}),
]);
// ── 5. Direkt einloggen → wirft NEXT_REDIRECT ─────────────────────
await signIn("credentials", {
email: user.email,
password,
redirectTo: "/dashboard",
});
// Unreachable signIn redirected.
return {};
}
+141
View File
@@ -0,0 +1,141 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { acceptInviteAction, type InviteState } from "./actions";
const initialState: InviteState = {};
// =====================================================================
// InviteForm · Client Component
// ---------------------------------------------------------------------
// Empfängt den Token als Prop (aus der Server Component) und übergibt
// ihn als Hidden-Input an die Server Action — der Client "kennt" den
// Token nur zum Weiterleiten, validiert ihn nicht selbst.
// =====================================================================
export function InviteForm({
token,
userName,
}: {
token: string;
userName: string;
}) {
const [state, formAction, pending] = useActionState(
acceptInviteAction,
initialState,
);
return (
<form action={formAction} className="space-y-5">
{/* Token als verstecktes Feld */}
<input type="hidden" name="token" value={token} />
<div className="space-y-1.5">
<p className="text-sm text-muted-foreground">
Du wurdest eingeladen als: <strong>{userName}</strong>
</p>
</div>
{/* Passwort */}
<div className="space-y-1.5">
<Label htmlFor="password">Passwort wählen</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
aria-invalid={!!state.errors?.password}
/>
{state.errors?.password?.[0] ? (
<p className="text-xs text-destructive">{state.errors.password[0]}</p>
) : (
<p className="text-xs text-muted-foreground">Mindestens 8 Zeichen.</p>
)}
</div>
{/* Passwort bestätigen */}
<div className="space-y-1.5">
<Label htmlFor="confirmPassword">Passwort bestätigen</Label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
aria-invalid={!!state.errors?.confirmPassword}
/>
{state.errors?.confirmPassword?.[0] && (
<p className="text-xs text-destructive">
{state.errors.confirmPassword[0]}
</p>
)}
</div>
{/* DSGVO-Pflicht */}
<div className="rounded-md border p-4 space-y-4">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Einwilligungen (Pflicht)
</p>
<div className="flex items-start gap-3">
<Checkbox
id="acceptPrivacyPolicy"
name="acceptPrivacyPolicy"
required
aria-invalid={!!state.errors?.acceptPrivacyPolicy}
/>
<div className="space-y-0.5">
<Label htmlFor="acceptPrivacyPolicy" className="text-sm font-normal">
Ich habe die{" "}
<a
href="/datenschutz"
className="underline"
target="_blank"
rel="noreferrer"
>
Datenschutzerklärung
</a>{" "}
gelesen und akzeptiere sie. <span className="text-destructive">*</span>
</Label>
{state.errors?.acceptPrivacyPolicy?.[0] && (
<p className="text-xs text-destructive">
{state.errors.acceptPrivacyPolicy[0]}
</p>
)}
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="directoryOptIn"
name="directoryOptIn"
aria-invalid={!!state.errors?.directoryOptIn}
/>
<div className="space-y-0.5">
<Label htmlFor="directoryOptIn" className="text-sm font-normal">
Ich stimme zu, dass meine Kontaktdaten im internen Kita-Adressbuch
für andere Eltern sichtbar sind (Opt-In, jederzeit widerrufbar).
</Label>
</div>
</div>
</div>
{/* Globaler Fehler */}
{state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{state.errors._form[0]}
</p>
)}
<Button type="submit" className="w-full" disabled={pending} size="lg">
{pending ? "Wird eingerichtet…" : "Konto aktivieren & anmelden"}
</Button>
</form>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { Baby } from "lucide-react";
import { prisma } from "@/lib/prisma";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { InviteForm } from "./invite-form";
export const metadata = { title: "Konto aktivieren · Kita-Planer" };
// =====================================================================
// /invite/[token] · Öffentliche Seite (kein Auth erforderlich)
// ---------------------------------------------------------------------
// 1. Token gegen DB prüfen (existiert + nicht abgelaufen)
// 2. User laden (Name für personalisierte Begrüßung)
// 3. Invite bereits eingelöst → Hinweis
// 4. Gültiger Token → InviteForm
// =====================================================================
export default async function InvitePage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
// Token in DB nachschlagen
const verificationToken = await prisma.verificationToken.findUnique({
where: { token },
});
// Unbekannter Token → 404
if (!verificationToken) {
notFound();
}
const isExpired = verificationToken.expires < new Date();
// User über identifier (= userId) laden
const user = await prisma.user.findUnique({
where: { id: verificationToken.identifier },
select: {
firstName: true,
lastName: true,
email: true,
passwordHash: true,
kita: { select: { name: true } },
},
});
if (!user) notFound();
const userName = `${user.firstName} ${user.lastName}`;
const alreadyAccepted = user.passwordHash !== "";
return (
<div className="flex min-h-screen flex-col bg-muted/30">
{/* Mini-Header */}
<header className="border-b bg-background">
<div className="mx-auto flex h-14 max-w-5xl items-center gap-2.5 px-6">
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Baby className="h-4 w-4" />
</div>
<span className="text-sm font-semibold">Kita-Planer</span>
{user.kita && (
<>
<span className="text-sm text-muted-foreground">·</span>
<span className="text-sm text-muted-foreground">
{user.kita.name}
</span>
</>
)}
</div>
</header>
<main className="flex flex-1 items-center justify-center px-4 py-12">
<Card className="w-full max-w-md">
<CardHeader className="space-y-2">
{isExpired ? (
<>
<CardTitle>Einladungslink abgelaufen</CardTitle>
<CardDescription>
Dieser Link ist nicht mehr gültig. Bitte wende dich an deinen
Kita-Administrator, um einen neuen Link zu erhalten.
</CardDescription>
</>
) : alreadyAccepted ? (
<>
<CardTitle>Konto bereits aktiviert</CardTitle>
<CardDescription>
Du hast diesen Einladungslink bereits verwendet. Melde dich
einfach mit deiner E-Mail-Adresse an.
</CardDescription>
</>
) : (
<>
<CardTitle>Willkommen, {user.firstName}!</CardTitle>
<CardDescription>
Du wurdest zur Kita <strong>{user.kita?.name ?? "—"}</strong>{" "}
eingeladen. Setze dein Passwort, um dein Konto zu aktivieren.
</CardDescription>
</>
)}
</CardHeader>
<CardContent>
{isExpired || alreadyAccepted ? (
<div className="text-center">
<Link
href="/login"
className="text-sm font-medium underline underline-offset-4"
>
Zur Anmeldung
</Link>
</div>
) : (
<InviteForm token={token} userName={userName} />
)}
</CardContent>
</Card>
</main>
</div>
);
}
+8 -4
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/sonner";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Kita-Planer",
description: "Der digitale Kita-Planer für Elterninitiativen",
};
export default function RootLayout({
@@ -24,10 +25,13 @@ export default function RootLayout({
}>) {
return (
<html
lang="en"
lang="de"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col">
{children}
<Toaster />
</body>
</html>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use server";
import { AuthError } from "next-auth";
import { z } from "zod";
import { signIn } from "@/auth";
// =====================================================================
// /login · Server Action
// ---------------------------------------------------------------------
// Wir kapseln `signIn("credentials", …)` damit:
// • das Formular `useActionState` mit typisiertem Fehler-State nutzen kann
// • der Redirect-Trigger (NEXT_REDIRECT) sauber durchgereicht wird
// • generische Fehler ("Login fehlgeschlagen") weder Username-Existenz
// noch Lockout-Status leaken (kein User-Enumeration).
// =====================================================================
const loginSchema = z.object({
email: z.string().email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase().trim(),
password: z.string().min(1, "Passwort fehlt."),
});
export type LoginState = {
errors?: {
email?: string[];
password?: string[];
_form?: string[];
};
};
export async function loginAction(
_prev: LoginState,
formData: FormData,
): Promise<LoginState> {
const parsed = loginSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
const { email, password } = parsed.data;
try {
await signIn("credentials", {
email,
password,
// Auth-Utils entscheiden im Folge-Render, ob /onboarding oder /dashboard.
// 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) {
// signIn wirft NEXT_REDIRECT bei Erfolg → das müssen wir bewusst
// weiterwerfen, sonst wird der Redirect verschluckt.
if (err instanceof AuthError) {
return {
errors: { _form: ["E-Mail oder Passwort ist nicht korrekt."] },
};
}
throw err;
}
return {};
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { loginAction, type LoginState } from "./actions";
const initialState: LoginState = {};
export function LoginForm() {
const [state, formAction, pending] = useActionState(loginAction, initialState);
return (
<form action={formAction} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">E-Mail</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
required
aria-invalid={!!state.errors?.email}
/>
{state.errors?.email?.[0] && (
<p className="text-xs text-destructive">{state.errors.email[0]}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
aria-invalid={!!state.errors?.password}
/>
{state.errors?.password?.[0] && (
<p className="text-xs text-destructive">{state.errors.password[0]}</p>
)}
</div>
{state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{state.errors._form[0]}
</p>
)}
<Button type="submit" className="w-full" disabled={pending}>
{pending ? "Anmelden…" : "Anmelden"}
</Button>
</form>
);
}
+42
View File
@@ -0,0 +1,42 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { LoginForm } from "./login-form";
export const metadata = { title: "Anmelden · Kita-Planer" };
export default async function LoginPage() {
const session = await auth();
if (session?.user) {
redirect(session.user.kitaId ? "/dashboard" : "/onboarding");
}
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
<Card className="w-full max-w-md">
<CardHeader className="space-y-2">
<CardTitle>Anmelden</CardTitle>
<CardDescription>Willkommen zurück.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<LoginForm />
<p className="text-center text-sm text-muted-foreground">
Noch keinen Account?{" "}
<Link href="/register" className="font-medium text-foreground underline">
Kita registrieren
</Link>
</p>
</CardContent>
</Card>
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
"use server";
import { redirect } from "next/navigation";
import { Prisma, UserRole } from "@prisma/client";
import { z } from "zod";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
// =====================================================================
// /onboarding · Server Action
// ---------------------------------------------------------------------
// Erstellt die Kita (Tenant) und verknüpft den eingeloggten Gründer in
// einer einzigen Datenbank-Transaktion. Damit ist garantiert: entweder
// existiert hinterher beides (Kita + verknüpfter Admin) oder gar nichts
// — 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."),
});
function checkboxToBool(value: unknown): boolean {
// HTML-Checkbox: "on" wenn aktiviert, sonst nicht im FormData.
return value === "on" || value === "true" || value === true;
}
export type OnboardingState = {
errors?: {
kitaName?: string[];
notdienstMinPerChildPerMonth?: string[];
_form?: string[];
};
};
export async function completeOnboardingAction(
_prev: OnboardingState,
formData: FormData,
): Promise<OnboardingState> {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
if (session.user.kitaId) {
// Onboarding bereits abgeschlossen — verhindert doppeltes Einrichten
// (z.B. durch zurück-navigieren oder Replay des Formulars).
redirect("/dashboard");
}
const parsed = onboardingSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
const data = parsed.data;
try {
await prisma.$transaction(async (tx) => {
const kita = await tx.kita.create({
data: {
name: data.kitaName,
slug: await generateUniqueSlug(tx, data.kitaName),
notdienstModuleEnabled: data.notdienstModuleEnabled,
terminModuleEnabled: data.terminModuleEnabled,
adressbuchModuleEnabled: data.adressbuchModuleEnabled,
notdienstMinPerChildPerMonth: data.notdienstMinPerChildPerMonth,
},
});
// Defense-in-Depth: erlaube den Update nur, wenn der User aktuell
// tatsächlich noch keine Kita hat (verhindert Race-Conditions bei
// parallelen Onboarding-Submits).
const updated = await tx.user.updateMany({
where: { id: session.user.id, kitaId: null },
data: {
kitaId: kita.id,
role: UserRole.ADMIN,
},
});
if (updated.count === 0) {
throw new Error("USER_ALREADY_HAS_KITA");
}
});
} catch (err) {
if (err instanceof Error && err.message === "USER_ALREADY_HAS_KITA") {
redirect("/dashboard");
}
if (err instanceof Prisma.PrismaClientKnownRequestError) {
return {
errors: { _form: ["Datenbankfehler — bitte erneut versuchen."] },
};
}
throw err;
}
// Frischer JWT wird beim nächsten Request automatisch neu befüllt
// (siehe jwt-Callback in auth.ts → re-validates from DB).
redirect("/dashboard");
}
// ---------------------------------------------------------------------
// Helfer
// ---------------------------------------------------------------------
async function generateUniqueSlug(
tx: Prisma.TransactionClient,
name: string,
): Promise<string> {
const base = slugify(name) || "kita";
let candidate = base;
let attempt = 0;
// In der Praxis genügen 1-2 Iterationen; max. 50 als Safety-Net.
while (attempt < 50) {
const exists = await tx.kita.findUnique({ where: { slug: candidate } });
if (!exists) return candidate;
attempt += 1;
candidate = `${base}-${attempt + 1}`;
}
return `${base}-${Date.now()}`;
}
function slugify(input: string): string {
return input
.toLowerCase()
.replace(/ß/g, "ss")
.normalize("NFKD")
// strippt kombinierende Diakritika (ä → a, ö → o, é → e …)
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")
.slice(0, 60);
}
+72
View File
@@ -0,0 +1,72 @@
import { redirect } from "next/navigation";
import { UserRole } from "@prisma/client";
import { requireSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export const metadata = { title: "Datenschutz-Einwilligung · Kita-Planer" };
// Diese Seite wird nur in einem sehr seltenen Edge-Case erreicht:
// Ein User mit kitaId hat noch kein `privacyPolicyAcceptedAt` gesetzt
// (z.B. über einen alten Seed oder manuellen DB-Eintrag).
// Im normalen Registrierungs-Flow wird die Einwilligung bereits
// in registerAction() zum Zeitpunkt der Kontoerstellung erfasst.
export default async function ConsentPage() {
const session = await requireSession();
// SUPERADMIN benötigt keinen Consent-Flow — hat keinen Kita-Kontext.
if (session.user.role === UserRole.SUPERADMIN) {
redirect("/admin");
}
// User ohne kitaId gehört in den Onboarding-Wizard.
if (!session.user.kitaId) {
redirect("/onboarding");
}
// Prüfen, ob der Consent wirklich fehlt (DB-seitiger Check).
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { privacyPolicyAcceptedAt: true },
});
// Consent bereits vorhanden → weiter ins Dashboard.
if (user?.privacyPolicyAcceptedAt) {
redirect("/dashboard");
}
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
<Card className="w-full max-w-md">
<CardHeader className="space-y-2">
<CardTitle>Datenschutz-Einwilligung erforderlich</CardTitle>
<CardDescription>
Dein Konto wurde ohne Datenschutz-Einwilligung angelegt. Bitte
wende dich an deinen Kita-Administrator oder den Support, um deinen
Account zu reaktivieren.
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
<p>
Falls du glaubst, dass dies ein Fehler ist, kontaktiere bitte{" "}
<a
href="mailto:support@kita-planer.de"
className="font-medium text-foreground underline"
>
support@kita-planer.de
</a>
.
</p>
</CardContent>
</Card>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { completeOnboardingAction, type OnboardingState } from "./actions";
const initialState: OnboardingState = {};
export function OnboardingForm() {
const [state, formAction, pending] = useActionState(
completeOnboardingAction,
initialState,
);
return (
<form action={formAction} className="space-y-8">
{/* ----- Schritt 1: Name ----- */}
<fieldset className="space-y-4">
<legend className="text-sm font-semibold text-muted-foreground">
Schritt 1 · Grunddaten
</legend>
<div className="space-y-1.5">
<Label htmlFor="kitaName">Name des Elternvereins / der Kita</Label>
<Input
id="kitaName"
name="kitaName"
required
placeholder="z.B. Waldameisen e.V."
aria-invalid={!!state.errors?.kitaName}
/>
{state.errors?.kitaName?.[0] && (
<p className="text-xs text-destructive">{state.errors.kitaName[0]}</p>
)}
</div>
</fieldset>
{/* ----- Schritt 2: Module ----- */}
<fieldset className="space-y-3">
<legend className="text-sm font-semibold text-muted-foreground">
Schritt 2 · Module aktivieren
</legend>
<ModuleCheckbox
name="notdienstModuleEnabled"
label="Notdienst-Planung"
description="Verfügbarkeiten erfassen, Plan generieren, bei Krankheitsausfall alarmieren."
defaultChecked
/>
<ModuleCheckbox
name="terminModuleEnabled"
label="Terminkalender"
description="Kita-Feste, Schließtage und private Anfragen koordinieren."
defaultChecked
/>
<ModuleCheckbox
name="adressbuchModuleEnabled"
label="Eltern-Adressbuch"
description="Eltern können sich auf Opt-In-Basis untereinander finden."
defaultChecked
/>
</fieldset>
{/* ----- Schritt 3: Notdienst-Regeln ----- */}
<fieldset className="space-y-4">
<legend className="text-sm font-semibold text-muted-foreground">
Schritt 3 · Notdienst-Regel
</legend>
<div className="space-y-1.5">
<Label htmlFor="notdienstMinPerChildPerMonth">
Mindest-Verfügbarkeiten pro Kind und Monat
</Label>
<Input
id="notdienstMinPerChildPerMonth"
name="notdienstMinPerChildPerMonth"
type="number"
min={0}
max={31}
defaultValue={2}
required
aria-invalid={!!state.errors?.notdienstMinPerChildPerMonth}
/>
<p className="text-xs text-muted-foreground">
Wie viele Tage müssen Eltern pro Monat als verfügbar markieren?
Diesen Wert kannst du später jederzeit ändern.
</p>
{state.errors?.notdienstMinPerChildPerMonth?.[0] && (
<p className="text-xs text-destructive">
{state.errors.notdienstMinPerChildPerMonth[0]}
</p>
)}
</div>
</fieldset>
{state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{state.errors._form[0]}
</p>
)}
<Button type="submit" className="w-full" disabled={pending} size="lg">
{pending ? "Wird eingerichtet…" : "Kita einrichten & loslegen"}
</Button>
</form>
);
}
function ModuleCheckbox({
name,
label,
description,
defaultChecked,
}: {
name: string;
label: string;
description: string;
defaultChecked?: boolean;
}) {
return (
<label className="flex items-start gap-3 rounded-md border p-4 transition-colors hover:bg-muted/40">
<Checkbox name={name} defaultChecked={defaultChecked} className="mt-0.5" />
<div className="grid gap-1 leading-none">
<span className="text-sm font-medium">{label}</span>
<span className="text-xs text-muted-foreground">{description}</span>
</div>
</label>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { redirect } from "next/navigation";
import { UserRole } from "@prisma/client";
import { requireSession } from "@/lib/auth-utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { OnboardingForm } from "./onboarding-form";
export const metadata = { title: "Kita einrichten · Kita-Planer" };
export default async function OnboardingPage() {
const session = await requireSession();
// Guard: User MIT bereits zugeordneter Kita gehört nicht hierhin.
if (session.user.kitaId) {
redirect("/dashboard");
}
// Superadmins haben grundsätzlich keine Kita; sie nutzen /admin.
if (session.user.role === UserRole.SUPERADMIN) {
redirect("/admin");
}
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
<Card className="w-full max-w-2xl">
<CardHeader className="space-y-2">
<CardTitle>Willkommen, {session.user.name?.split(" ")[0] ?? "Gründer:in"}!</CardTitle>
<CardDescription>
Lass uns deine Kita in 3 kurzen Schritten einrichten. Du kannst alle
Einstellungen später noch anpassen.
</CardDescription>
</CardHeader>
<CardContent>
<OnboardingForm />
</CardContent>
</Card>
</div>
);
}
+96 -57
View File
@@ -1,65 +1,104 @@
import Image from "next/image";
import Link from "next/link";
import { redirect } from "next/navigation";
import { CalendarCheck2, ShieldCheck, Users } from "lucide-react";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
// Eingeloggte User von der Landingpage direkt weiterleiten — die Routing-
// Logik selbst (Onboarding vs. Dashboard) übernimmt `requireKitaSession`.
export default async function LandingPage() {
const session = await auth();
if (session?.user) {
redirect(session.user.kitaId ? "/dashboard" : "/onboarding");
}
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<div className="flex min-h-screen flex-col">
<header className="border-b">
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-6">
<span className="text-lg font-semibold">Kita-Planer</span>
<nav className="flex items-center gap-3">
<Button asChild variant="ghost">
<Link href="/login">Anmelden</Link>
</Button>
<Button asChild>
<Link href="/register">Kita registrieren</Link>
</Button>
</nav>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
</header>
<main className="flex-1">
<section className="mx-auto w-full max-w-6xl px-6 py-24">
<div className="max-w-3xl">
<p className="mb-4 inline-block rounded-full bg-secondary px-3 py-1 text-xs font-medium text-secondary-foreground">
Für Elterninitiativen & Elternvereine
</p>
<h1 className="text-balance text-4xl font-semibold tracking-tight sm:text-5xl">
Der digitale Kita-Planer für Elterninitiativen.
</h1>
<p className="mt-6 text-lg text-muted-foreground">
Notdienst-Planung, Terminkalender und Stammdaten endlich
an einem Ort. Schluss mit Excel-Tabellen, WhatsApp-Listen und
vergessenen Geburtstagen.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Button asChild size="lg">
<Link href="/register">Kita kostenlos registrieren</Link>
</Button>
<Button asChild size="lg" variant="outline">
<Link href="/login">Bereits Mitglied? Anmelden</Link>
</Button>
</div>
</div>
<div className="mt-20 grid gap-8 sm:grid-cols-3">
<FeatureCard
icon={<ShieldCheck className="h-6 w-6" />}
title="Notdienst-Planung"
description="Verfügbarkeiten erfassen, fairen Plan automatisch generieren, Eltern bei Krankheitsausfall sofort alarmieren."
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
<FeatureCard
icon={<CalendarCheck2 className="h-6 w-6" />}
title="Terminkalender"
description="Kita-Feste, Schließtage und private Anfragen mit Mitbringsel-Listen — übersichtlich für alle Eltern."
/>
<FeatureCard
icon={<Users className="h-6 w-6" />}
title="Eltern-Adressbuch"
description="Spielverabredungen leichter machen — auf Opt-In-Basis und DSGVO-konform."
/>
</div>
</section>
</main>
<footer className="border-t">
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-6 text-sm text-muted-foreground">
<span>© {new Date().getFullYear()} Kita-Planer</span>
<span>Made with für Elternvereine</span>
</div>
</footer>
</div>
);
}
function FeatureCard({
icon,
title,
description,
}: {
icon: React.ReactNode;
title: string;
description: string;
}) {
return (
<div className="rounded-lg border bg-card p-6">
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-md bg-secondary text-secondary-foreground">
{icon}
</div>
<h3 className="mb-2 text-lg font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
"use server";
import { hash } from "bcryptjs";
import { Prisma, UserRole } from "@prisma/client";
import { z } from "zod";
import { signIn } from "@/auth";
import { prisma } from "@/lib/prisma";
// =====================================================================
// /register · Server Action
// ---------------------------------------------------------------------
// Legt einen "heimatlosen" Admin-Account an (kitaId === null) und loggt
// ihn direkt ein. Die Tenant-Erstellung folgt im Onboarding-Wizard.
// =====================================================================
const registerSchema = z.object({
email: z.string().email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase().trim(),
// bcrypt akzeptiert max. 72 Bytes — wir capen davor zur Sicherheit
password: z
.string()
.min(8, "Mindestens 8 Zeichen.")
.max(72, "Maximal 72 Zeichen."),
firstName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
lastName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
// HTML-Checkboxen senden "on" wenn aktiviert, sonst nichts
acceptPrivacyPolicy: z.literal("on", {
error: "Bitte Datenschutzerklärung akzeptieren.",
}),
});
const PRIVACY_POLICY_VERSION = "2026-05-01";
export type RegisterState = {
errors?: {
email?: string[];
password?: string[];
firstName?: string[];
lastName?: string[];
acceptPrivacyPolicy?: string[];
_form?: string[];
};
};
export async function registerAction(
_prevState: RegisterState,
formData: FormData,
): Promise<RegisterState> {
const parsed = registerSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
const { email, password, firstName, lastName } = parsed.data;
const passwordHash = await hash(password, 12);
const now = new Date();
try {
await prisma.user.create({
data: {
email,
passwordHash,
firstName,
lastName,
// Gründer wird "heimatloser" Admin — beim Onboarding-Abschluss
// wird die Rolle bestätigt und der User mit einer Kita verknüpft.
role: UserRole.ADMIN,
kitaId: null,
privacyPolicyAcceptedAt: now,
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
emailVerifiedAt: now,
},
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
return {
errors: {
email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."],
},
};
}
throw err;
}
// Direkt einloggen → wirft NEXT_REDIRECT, das wir nicht abfangen.
await signIn("credentials", {
email,
password,
redirectTo: "/onboarding",
});
// Unreachable signIn redirected.
return {};
}
+44
View File
@@ -0,0 +1,44 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { RegisterForm } from "./register-form";
export const metadata = { title: "Registrieren · Kita-Planer" };
export default async function RegisterPage() {
const session = await auth();
if (session?.user) {
redirect(session.user.kitaId ? "/dashboard" : "/onboarding");
}
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
<Card className="w-full max-w-md">
<CardHeader className="space-y-2">
<CardTitle>Kita kostenlos registrieren</CardTitle>
<CardDescription>
Lege deinen Account an. Im nächsten Schritt richtest du deine Kita ein.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<RegisterForm />
<p className="text-center text-sm text-muted-foreground">
Bereits einen Account?{" "}
<Link href="/login" className="font-medium text-foreground underline">
Anmelden
</Link>
</p>
</CardContent>
</Card>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { registerAction, type RegisterState } from "./actions";
const initialState: RegisterState = {};
export function RegisterForm() {
const [state, formAction, pending] = useActionState(registerAction, initialState);
return (
<form action={formAction} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<FormField
id="firstName"
name="firstName"
label="Vorname"
autoComplete="given-name"
required
error={state.errors?.firstName?.[0]}
/>
<FormField
id="lastName"
name="lastName"
label="Nachname"
autoComplete="family-name"
required
error={state.errors?.lastName?.[0]}
/>
</div>
<FormField
id="email"
name="email"
type="email"
label="E-Mail"
autoComplete="email"
required
error={state.errors?.email?.[0]}
/>
<FormField
id="password"
name="password"
type="password"
label="Passwort"
autoComplete="new-password"
required
helperText="Mindestens 8 Zeichen."
error={state.errors?.password?.[0]}
/>
<div className="flex items-start gap-3 pt-2">
<Checkbox id="acceptPrivacyPolicy" name="acceptPrivacyPolicy" required />
<div className="grid gap-1 leading-none">
<Label htmlFor="acceptPrivacyPolicy" className="text-sm font-normal">
Ich habe die{" "}
<a href="/datenschutz" className="underline" target="_blank" rel="noreferrer">
Datenschutzerklärung
</a>{" "}
gelesen und akzeptiere sie.
</Label>
{state.errors?.acceptPrivacyPolicy?.[0] && (
<p className="text-xs text-destructive">{state.errors.acceptPrivacyPolicy[0]}</p>
)}
</div>
</div>
{state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{state.errors._form[0]}
</p>
)}
<Button type="submit" className="w-full" disabled={pending}>
{pending ? "Wird erstellt…" : "Account erstellen"}
</Button>
</form>
);
}
function FormField({
id,
name,
label,
type = "text",
required,
autoComplete,
helperText,
error,
}: {
id: string;
name: string;
label: string;
type?: string;
required?: boolean;
autoComplete?: string;
helperText?: string;
error?: string;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
name={name}
type={type}
required={required}
autoComplete={autoComplete}
aria-invalid={!!error}
/>
{error ? (
<p className="text-xs text-destructive">{error}</p>
) : helperText ? (
<p className="text-xs text-muted-foreground">{helperText}</p>
) : null}
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { compare } from "bcryptjs";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
// =====================================================================
// NextAuth.js (Auth.js v5) · Credentials-Provider mit JWT-Strategie
// ---------------------------------------------------------------------
// Mandantenfähigkeit: `id`, `role`, `kitaId` werden über die JWT-/Session-
// Callbacks aus der DB in jede Session durchgeschleift, damit jede
// Server Action / API-Route den Tenant-Filter setzen kann.
// =====================================================================
const credentialsSchema = z.object({
email: z.string().email().toLowerCase().trim(),
password: z.string().min(1),
});
// Brute-Force-Schutz auf Account-Ebene (planung.md §5.5).
// Globales Rate-Limiting ergänzen wir später per Middleware.
const MAX_FAILED_ATTEMPTS = 10;
const LOCKOUT_MINUTES = 15;
export const { handlers, auth, signIn, signOut } = NextAuth({
session: { strategy: "jwt" },
pages: {
signIn: "/login",
},
providers: [
Credentials({
name: "Credentials",
credentials: {
email: { label: "E-Mail", type: "email" },
password: { label: "Passwort", type: "password" },
},
async authorize(rawCredentials) {
const parsed = credentialsSchema.safeParse(rawCredentials);
if (!parsed.success) return null;
const { email, password } = parsed.data;
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return null;
// Account temporär gesperrt → Login pauschal ablehnen.
if (user.lockedUntil && user.lockedUntil > new Date()) {
return null;
}
const passwordOk = await compare(password, user.passwordHash);
if (!passwordOk) {
const nextAttempts = user.failedLoginAttempts + 1;
await prisma.user.update({
where: { id: user.id },
data: {
failedLoginAttempts: nextAttempts,
lockedUntil:
nextAttempts >= MAX_FAILED_ATTEMPTS
? new Date(Date.now() + LOCKOUT_MINUTES * 60_000)
: user.lockedUntil,
},
});
return null;
}
// Erfolgreicher Login → Counter zurücksetzen, lastLogin aktualisieren.
await prisma.user.update({
where: { id: user.id },
data: {
failedLoginAttempts: 0,
lockedUntil: null,
lastLoginAt: new Date(),
},
});
return {
id: user.id,
email: user.email,
name: `${user.firstName} ${user.lastName}`.trim(),
role: user.role,
kitaId: user.kitaId,
};
},
}),
],
callbacks: {
/**
* Wird beim Login (mit `user`) und bei jedem Token-Refresh (ohne `user`)
* aufgerufen. Beim Login schreiben wir die mandantenrelevanten Felder
* einmalig in den Token; bei jedem weiteren Aufruf re-validieren wir
* `role` und `kitaId` gegen die DB, damit z.B. ein gerade entzogener
* Admin-Rang sofort greift (kein 30-Tage-Token mit veralteter Rolle).
*/
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = user.role;
token.kitaId = user.kitaId;
return token;
}
if (token.id) {
const fresh = await prisma.user.findUnique({
where: { id: token.id },
select: { role: true, kitaId: true },
});
if (!fresh) {
// User wurde gelöscht → Token entwerten.
// (Auth.js erkennt den fehlenden `sub`/`id` und meldet ab.)
delete (token as Partial<typeof token>).id;
return token;
}
token.role = fresh.role;
token.kitaId = fresh.kitaId;
}
return token;
},
/**
* Wird bei jedem `auth()` / `useSession()`-Aufruf ausgeführt.
* Hier projizieren wir die JWT-Felder in das Session-Objekt,
* damit Server Components / Client Components sie typsicher lesen können.
*/
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id;
session.user.role = token.role;
session.user.kitaId = token.kitaId;
}
return session;
},
},
});
+95
View File
@@ -0,0 +1,95 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LayoutDashboard, Users, CalendarCheck2, CalendarDays, Contact, GraduationCap, UserCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { UserRole } from "@prisma/client";
const navItems = [
{
href: "/dashboard",
label: "Übersicht",
icon: LayoutDashboard,
exact: true,
},
{
href: "/dashboard/notdienst",
label: "Notdienst (Eltern)",
icon: CalendarCheck2,
exact: true,
},
{
href: "/dashboard/notdienst/plan",
label: "Notdienst-Planung",
icon: CalendarCheck2,
exact: false,
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR],
},
{
href: "/dashboard/kalender",
label: "Terminkalender",
icon: CalendarDays,
exact: false,
},
{
href: "/dashboard/adressbuch",
label: "Adressbuch",
icon: Contact,
exact: false,
},
{
href: "/dashboard/families",
label: "Familienverwaltung",
icon: Users,
exact: false,
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR],
},
{
href: "/dashboard/erzieher",
label: "ErzieherInnen",
icon: GraduationCap,
exact: false,
allowedRoles: [UserRole.ADMIN],
},
{
href: "/dashboard/profil",
label: "Mein Profil",
icon: UserCircle,
exact: false,
},
];
export function SidebarNav({ role }: { role: UserRole }) {
const pathname = usePathname();
return (
<nav className="flex flex-col gap-1 px-3">
{navItems.map(({ href, label, icon: Icon, exact, allowedRoles }) => {
// Filter out items not allowed for the current role
if (allowedRoles && !(allowedRoles as UserRole[]).includes(role)) {
return null;
}
const isActive = exact ? pathname === href : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
</Link>
);
})}
</nav>
);
}
+40
View File
@@ -0,0 +1,40 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground",
secondary:
"border-transparent bg-secondary text-secondary-foreground",
destructive:
"border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground",
success:
"border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400",
warning:
"border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
+62
View File
@@ -0,0 +1,62 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
/>
),
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
{...props}
/>
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
),
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
+28
View File
@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
+119
View File
@@ -0,0 +1,119 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Schließen</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };
+26
View File
@@ -0,0 +1,26 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+31
View File
@@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ComponentRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster };
+101
View File
@@ -0,0 +1,101 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle text-xs font-medium uppercase tracking-wide text-muted-foreground [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("px-4 py-3 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
TableCaption,
};
+186
View File
@@ -0,0 +1,186 @@
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
type AlertEmailProps = {
date: string;
childName: string;
confirmLink: string;
};
export function AlertEmail({
date,
childName,
confirmLink,
}: AlertEmailProps) {
return (
<Html lang="de">
<Head />
<Preview>Dringender Notdienst-Alarm fuer {date}</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
<Section style={styles.alertBar}>
<Text style={styles.kicker}>Dringend</Text>
<Heading style={styles.heading}>Notdienst heute bestaetigen</Heading>
<Text style={styles.lead}>
Eine Fachkraft ist ausgefallen. Fuer {childName} ist ein
Notdienst-Einsatz am {date} hinterlegt.
</Text>
</Section>
<Section style={styles.card}>
<Text style={styles.text}>
Bitte bestaetige schnell, ob du den Notdienst uebernehmen kannst,
damit die Kita den Tag verlaesslich planen kann.
</Text>
<Button href={confirmLink} style={styles.button}>
Notdienst bestaetigen
</Button>
<Section style={styles.detailBox}>
<Text style={styles.detailLabel}>Einsatzdatum</Text>
<Text style={styles.detailValue}>{date}</Text>
<Text style={styles.detailLabel}>Kind</Text>
<Text style={styles.detailValue}>{childName}</Text>
</Section>
<Text style={styles.fallbackText}>
Falls der Button nicht funktioniert, kopiere diesen Link in deinen
Browser:
</Text>
<Text style={styles.linkText}>{confirmLink}</Text>
</Section>
<Hr style={styles.hr} />
<Text style={styles.footer}>
Diese Nachricht wurde automatisch vom Kita-Planer versendet.
</Text>
</Container>
</Body>
</Html>
);
}
const styles = {
body: {
margin: 0,
backgroundColor: "#fff7ed",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
color: "#30170f",
},
container: {
width: "100%",
maxWidth: "600px",
margin: "0 auto",
padding: "32px 20px",
},
alertBar: {
padding: "26px 28px",
backgroundColor: "#9f1239",
borderRadius: "8px 8px 0 0",
},
kicker: {
display: "inline-block",
margin: "0 0 18px",
padding: "6px 10px",
backgroundColor: "#fed7aa",
color: "#7f1d1d",
borderRadius: "999px",
fontSize: "12px",
fontWeight: 800,
letterSpacing: "0.08em",
textTransform: "uppercase" as const,
},
heading: {
margin: "0 0 12px",
color: "#ffffff",
fontSize: "30px",
lineHeight: "1.16",
fontWeight: 850,
},
lead: {
margin: 0,
color: "#ffe4e6",
fontSize: "16px",
lineHeight: "1.55",
},
card: {
padding: "28px",
backgroundColor: "#ffffff",
border: "1px solid #fed7aa",
borderTop: "0",
borderRadius: "0 0 8px 8px",
},
text: {
margin: "0 0 22px",
color: "#44251a",
fontSize: "16px",
lineHeight: "1.65",
},
button: {
display: "inline-block",
padding: "14px 22px",
backgroundColor: "#ea580c",
color: "#ffffff",
borderRadius: "6px",
fontSize: "15px",
fontWeight: 800,
textDecoration: "none",
},
detailBox: {
margin: "28px 0 0",
padding: "18px",
backgroundColor: "#fff7ed",
border: "1px solid #fdba74",
borderRadius: "6px",
},
detailLabel: {
margin: "0 0 4px",
color: "#9a3412",
fontSize: "12px",
fontWeight: 800,
letterSpacing: "0.06em",
textTransform: "uppercase" as const,
},
detailValue: {
margin: "0 0 14px",
color: "#30170f",
fontSize: "16px",
fontWeight: 700,
},
fallbackText: {
margin: "26px 0 8px",
color: "#7c5a4b",
fontSize: "13px",
lineHeight: "1.5",
},
linkText: {
margin: 0,
color: "#be123c",
fontSize: "13px",
lineHeight: "1.5",
wordBreak: "break-all" as const,
},
hr: {
margin: "24px 0",
borderColor: "#fed7aa",
},
footer: {
margin: 0,
color: "#8b6f63",
fontSize: "12px",
lineHeight: "1.5",
textAlign: "center" as const,
},
};
+155
View File
@@ -0,0 +1,155 @@
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
type InviteEmailProps = {
parentName: string;
kitaName: string;
inviteLink: string;
};
export function InviteEmail({
parentName,
kitaName,
inviteLink,
}: InviteEmailProps) {
return (
<Html lang="de">
<Head />
<Preview>Aktiviere deinen Kita-Planer Account fuer {kitaName}</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
<Section style={styles.header}>
<Text style={styles.brand}>Kita-Planer</Text>
<Heading style={styles.heading}>Willkommen bei {kitaName}</Heading>
<Text style={styles.lead}>
Hallo {parentName}, dein Eltern-Account wurde vorbereitet.
</Text>
</Section>
<Section style={styles.card}>
<Text style={styles.text}>
Ueber den folgenden Link kannst du dein Passwort setzen und deinen
Account aktivieren. Danach hast du Zugriff auf die Kita-Planung,
Termine und deine Familiendaten.
</Text>
<Button href={inviteLink} style={styles.button}>
Account aktivieren
</Button>
<Text style={styles.fallbackText}>
Falls der Button nicht funktioniert, kopiere diesen Link in deinen
Browser:
</Text>
<Text style={styles.linkText}>{inviteLink}</Text>
</Section>
<Hr style={styles.hr} />
<Text style={styles.footer}>
Diese Einladung wurde von deiner Kita erstellt. Wenn du sie nicht
erwartet hast, kannst du diese E-Mail ignorieren.
</Text>
</Container>
</Body>
</Html>
);
}
const styles = {
body: {
margin: 0,
backgroundColor: "#f6f7f2",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
color: "#1f2a24",
},
container: {
width: "100%",
maxWidth: "600px",
margin: "0 auto",
padding: "32px 20px",
},
header: {
padding: "28px 28px 20px",
backgroundColor: "#1f3b2d",
borderRadius: "8px 8px 0 0",
},
brand: {
margin: "0 0 28px",
color: "#d8f2bd",
fontSize: "13px",
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase" as const,
},
heading: {
margin: "0 0 12px",
color: "#ffffff",
fontSize: "30px",
lineHeight: "1.18",
fontWeight: 800,
},
lead: {
margin: 0,
color: "#e7f3e6",
fontSize: "16px",
lineHeight: "1.55",
},
card: {
padding: "28px",
backgroundColor: "#ffffff",
borderRadius: "0 0 8px 8px",
border: "1px solid #e0e5dc",
borderTop: "0",
},
text: {
margin: "0 0 24px",
color: "#344139",
fontSize: "16px",
lineHeight: "1.65",
},
button: {
display: "inline-block",
padding: "14px 22px",
backgroundColor: "#f0b84b",
color: "#172119",
borderRadius: "6px",
fontSize: "15px",
fontWeight: 800,
textDecoration: "none",
},
fallbackText: {
margin: "28px 0 8px",
color: "#66736b",
fontSize: "13px",
lineHeight: "1.5",
},
linkText: {
margin: 0,
color: "#2f6b4f",
fontSize: "13px",
lineHeight: "1.5",
wordBreak: "break-all" as const,
},
hr: {
margin: "24px 0",
borderColor: "#dfe5d9",
},
footer: {
margin: 0,
color: "#7b857d",
fontSize: "12px",
lineHeight: "1.5",
textAlign: "center" as const,
},
};
+93
View File
@@ -0,0 +1,93 @@
import { redirect } from "next/navigation";
import type { Session } from "next-auth";
import { UserRole } from "@prisma/client";
import { auth } from "@/auth";
// =====================================================================
// Auth-Utils für Server Components, Server Actions, Route Handlers
// ---------------------------------------------------------------------
// Wir kapseln die Session-Auflösung an einer Stelle, damit jede
// geschützte Route denselben Sicherheits-Pfad geht:
//
// 1. `getSession()` → optional, gibt `null` zurück
// 2. `requireSession()` → eingeloggt, sonst Redirect /login
// 3. `requireKitaSession()` → eingeloggt + kitaId vorhanden + Privacy-Consent
// 4. `requireRole()` → zusätzlich Rollen-Whitelist
//
// Mandanten-Isolation: NIEMALS `session.user.kitaId` ungeprüft an Prisma
// reichen. Über `requireKitaSession` ist garantiert, dass der Wert
// nicht-null ist und der Consent-Zeitstempel gesetzt wurde.
// =====================================================================
export type AuthenticatedSession = Session & {
user: NonNullable<Session["user"]>;
};
export type KitaSession = AuthenticatedSession & {
user: AuthenticatedSession["user"] & { kitaId: string };
};
export async function getSession(): Promise<Session | null> {
return auth();
}
export async function requireSession(): Promise<AuthenticatedSession> {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return session as AuthenticatedSession;
}
/**
* Garantiert: eingeloggter User MIT Mandantenzuordnung UND akzeptierter
* Datenschutzerklärung. Genau diese Funktion ist der Single-Point-of-Truth
* für alle Tenant-gebundenen Server Actions / Page Components.
*/
export async function requireKitaSession(): Promise<KitaSession> {
const session = await requireSession();
if (!session.user.kitaId) {
// Superadmins haben keine Kita → eigene Oberfläche.
if (session.user.role === UserRole.SUPERADMIN) {
redirect("/admin");
}
// Frisch registrierte Gründer → in den Onboarding-Wizard.
redirect("/onboarding");
}
// DSGVO-Gate: Ohne Privacy-Consent → erst Consent einholen.
// Wir prüfen das hier zentral statt in jeder Route einzeln.
const consent = await consentCheck(session.user.id);
if (!consent) {
redirect("/onboarding/consent");
}
return session as KitaSession;
}
export async function requireRole(
allowed: UserRole[],
): Promise<AuthenticatedSession> {
const session = await requireSession();
if (!allowed.includes(session.user.role)) {
redirect("/forbidden");
}
return session;
}
// ---------------------------------------------------------------------
// interne Helfer
// ---------------------------------------------------------------------
async function consentCheck(userId: string): Promise<boolean> {
// Lazy-Import, damit `auth-utils` selbst Edge-kompatibel bleibt
// (Prisma läuft nur in Node-Runtime).
const { prisma } = await import("@/lib/prisma");
const user = await prisma.user.findUnique({
where: { id: userId },
select: { privacyPolicyAcceptedAt: true },
});
return !!user?.privacyPolicyAcceptedAt;
}
+48
View File
@@ -0,0 +1,48 @@
import {
addMonths,
endOfMonth,
getDate,
getYear,
getMonth,
isWeekend,
eachDayOfInterval,
startOfMonth,
startOfDay,
format,
} from "date-fns";
import { de } from "date-fns/locale";
/**
* Der Notdienst-Plan wird immer für den nächsten Monat erstellt.
* Nach dem 26. des aktuellen Monats ist der Plan gesperrt.
*/
const LOCK_DAY = 26;
export function getTargetMonthData(now = new Date()) {
const currentDay = getDate(now);
const nextMonthDate = addMonths(now, 1);
const targetYear = getYear(nextMonthDate);
const targetMonth = getMonth(nextMonthDate) + 1; // 1-12
// Sperre aktiv, sobald der aktuelle Tag (im aktuellen Monat) > LOCK_DAY ist.
const isLocked = currentDay > LOCK_DAY;
return {
targetYear,
targetMonth,
isLocked,
monthName: format(nextMonthDate, "MMMM yyyy", { locale: de }),
};
}
export function getWorkingDaysOfMonth(year: number, month: number) {
// month ist 1-12, Date erwartet 0-11
const start = startOfMonth(new Date(year, month - 1));
const end = endOfMonth(start);
const days = eachDayOfInterval({ start, end });
return days
.filter((day) => !isWeekend(day))
.map((day) => startOfDay(day));
}
+76
View File
@@ -0,0 +1,76 @@
import type { ReactNode } from "react";
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
type SendAppEmailInput = {
to: string | string[];
subject: string;
react: ReactNode;
from?: string;
};
type SendAppEmailResult =
| { success: true; id?: string }
| { success: false; error: string };
export function getAppEmailConfigError() {
if (!process.env.RESEND_API_KEY) {
return "RESEND_API_KEY ist nicht konfiguriert.";
}
if (!process.env.EMAIL_FROM) {
return "EMAIL_FROM ist nicht konfiguriert.";
}
return null;
}
export async function sendAppEmail({
to,
subject,
react,
from = process.env.EMAIL_FROM,
}: SendAppEmailInput): Promise<SendAppEmailResult> {
if (!process.env.RESEND_API_KEY) {
return {
success: false,
error: "RESEND_API_KEY ist nicht konfiguriert.",
};
}
if (!from) {
return {
success: false,
error: "EMAIL_FROM ist nicht konfiguriert.",
};
}
try {
const { data, error } = await resend.emails.send({
from: from!,
to,
subject,
react,
});
if (error) {
console.error("Resend konnte die E-Mail nicht versenden:", error);
return {
success: false,
error: error.message ?? "E-Mail-Versand fehlgeschlagen.",
};
}
return { success: true, id: data?.id };
} catch (error) {
console.error("Unerwarteter Fehler beim E-Mail-Versand:", error);
return {
success: false,
error:
error instanceof Error
? error.message
: "Unerwarteter Fehler beim E-Mail-Versand.",
};
}
}
+21
View File
@@ -0,0 +1,21 @@
import { PrismaClient } from "@prisma/client";
// Singleton-Pattern: Im Dev-Modus erzeugt Next.js bei jedem Hot-Reload
// neue Modul-Instanzen → ohne Cache landen schnell zu viele DB-Connections
// im Pool ("Too many connections"). Wir hängen den Client an `globalThis`.
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["error", "warn"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+79
View File
@@ -0,0 +1,79 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { auth } from "@/auth";
// =====================================================================
// Kita-Planer · Proxy (ehem. Middleware, seit Next.js 16 umbenannt)
// ---------------------------------------------------------------------
// Optimistischer Guard — prüft den JWT-Cookie direkt, ohne DB-Call.
// Der sichere DB-seitige Check (Consent, Rolle) findet in den
// jeweiligen Server Components via `requireKitaSession()` statt.
//
// Routing-Logik:
// Nicht eingeloggt + geschützte Route → /login
// Eingeloggt + kein kitaId + nicht /onboarding → /onboarding
// Eingeloggt + hat kitaId + auf /onboarding → /dashboard
// Eingeloggt + auf /login oder /register → /
// =====================================================================
const PUBLIC_ROUTES = ["/", "/login", "/register", "/datenschutz"];
const ONBOARDING_ROUTE = "/onboarding";
const PROTECTED_PREFIX = ["/dashboard", "/admin", "/onboarding"];
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// Auth.js liest den Session-JWT aus dem Cookie —
// kein DB-Call, pure Token-Verifikation (Edge-sicher).
const session = await auth();
const user = session?.user;
const isPublicRoute = PUBLIC_ROUTES.some(
(route) => pathname === route || pathname.startsWith(route + "/"),
);
const isProtectedRoute = PROTECTED_PREFIX.some((prefix) =>
pathname.startsWith(prefix),
);
// ── 1. Nicht eingeloggt + geschützte Route ──────────────────────────
if (!user && isProtectedRoute) {
const loginUrl = new URL("/login", request.nextUrl);
// callbackUrl für spätere Nutzung (optional)
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
// ── 2. Eingeloggt ───────────────────────────────────────────────────
if (user) {
// 2a. Eingeloggter User auf Login/Register-Seite → Startseite,
// die dann selbst zu /dashboard oder /onboarding redirectet.
if (pathname === "/login" || pathname === "/register") {
return NextResponse.redirect(new URL("/", request.nextUrl));
}
// 2b. Kein kitaId (heimatloser Admin) → muss Onboarding abschließen.
// Ausnahme: Superadmin hat nie eine kitaId.
if (
!user.kitaId &&
user.role !== "SUPERADMIN" &&
!pathname.startsWith(ONBOARDING_ROUTE)
) {
return NextResponse.redirect(new URL(ONBOARDING_ROUTE, request.nextUrl));
}
// 2c. Hat kitaId und will /onboarding aufrufen → direkt ins Dashboard.
if (user.kitaId && pathname.startsWith(ONBOARDING_ROUTE)) {
return NextResponse.redirect(new URL("/dashboard", request.nextUrl));
}
}
return NextResponse.next();
}
// Proxy auf alle sinnvollen Routen anwenden; statische Dateien,
// API-Routen und Next.js-interne Routen ausschließen.
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
],
};
+39
View File
@@ -0,0 +1,39 @@
import type { DefaultSession, DefaultUser } from "next-auth";
import type { UserRole } from "@prisma/client";
// =====================================================================
// Type-Augmentation für NextAuth (Auth.js v5)
// ---------------------------------------------------------------------
// Erzwingt zur Compile-Zeit, dass `id`, `role` und `kitaId` in
// `Session.user` und im JWT verfügbar sind. Damit ist die
// Mandanten-Isolation auf Typ-Ebene abgesichert: Jede Server Action /
// Server Component, die `session.user.kitaId` nutzt, scheitert beim
// Build, falls die Eigenschaft je entfernt wird.
// =====================================================================
declare module "next-auth" {
interface User extends DefaultUser {
id: string;
role: UserRole;
kitaId: string | null;
}
interface Session {
user: {
id: string;
role: UserRole;
kitaId: string | null;
} & DefaultSession["user"];
}
}
// NextAuth v5 re-exportiert die JWT-Typen aus `@auth/core/jwt`.
// Module-Augmentation muss daher dort ansetzen — `next-auth/jwt` würde
// ignoriert.
declare module "@auth/core/jwt" {
interface JWT {
id: string;
role: UserRole;
kitaId: string | null;
}
}