Add non-destructive dev seed on startup
This commit is contained in:
@@ -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." };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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." };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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." };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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." };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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." };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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." };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user