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
+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>
);
}