continued the kita-planer

This commit is contained in:
t.indorf
2026-05-08 14:32:14 +02:00
parent b686e714ff
commit 7aff691803
85 changed files with 9434 additions and 588 deletions
+279
View File
@@ -0,0 +1,279 @@
"use client";
import Link from "next/link";
import { useMemo, useTransition } from "react";
import { AbsenceReason } from "@prisma/client";
import { Baby, CalendarX2, Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { deleteAbsence, reportAbsence } from "@/actions/absences";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
type ChildOption = {
id: string;
name: string;
};
type AbsenceListItem = {
id: string;
childName: string;
reason: AbsenceReason;
note: string | null;
startDate: string;
endDate: string;
};
export function AbsenceCard({
childOptions,
absences,
today,
}: {
childOptions: ChildOption[];
absences: AbsenceListItem[];
today: string;
}) {
const [isPending, startTransition] = useTransition();
const defaultChildId = childOptions[0]?.id;
const hasChildren = childOptions.length > 0;
function handleSubmit(formData: FormData) {
startTransition(async () => {
const result = await reportAbsence({
childId: String(formData.get("childId") ?? ""),
startDate: String(formData.get("startDate") ?? ""),
endDate: String(formData.get("endDate") ?? ""),
reason: String(formData.get("reason") ?? ""),
note: String(formData.get("note") ?? ""),
});
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Abwesenheit eingetragen.");
});
}
function handleDelete(absenceId: string) {
startTransition(async () => {
const result = await deleteAbsence(absenceId);
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Abwesenheit storniert.");
});
}
return (
<Card className="mb-8 border-amber-200 bg-amber-50/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<CalendarX2 className="h-5 w-5 text-amber-700" />
Kind abmelden
</CardTitle>
<CardDescription>
Melde dein Kind für ganze Tage ab. Die Kita sieht die Meldung direkt
in der Tagesübersicht.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
{hasChildren ? (
<form action={handleSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label>Welches Kind?</Label>
<Select name="childId" defaultValue={defaultChildId} required>
<SelectTrigger>
<SelectValue placeholder="Kind auswählen" />
</SelectTrigger>
<SelectContent>
{childOptions.map((child) => (
<SelectItem key={child.id} value={child.id}>
{child.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="absence-startDate">Von</Label>
<Input
id="absence-startDate"
name="startDate"
type="date"
defaultValue={today}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="absence-endDate">Bis</Label>
<Input
id="absence-endDate"
name="endDate"
type="date"
defaultValue={today}
required
/>
</div>
</div>
<div className="grid gap-2">
<Label>Grund</Label>
<Select name="reason" defaultValue={AbsenceReason.ILLNESS} required>
<SelectTrigger>
<SelectValue placeholder="Grund auswählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value={AbsenceReason.ILLNESS}>Krank</SelectItem>
<SelectItem value={AbsenceReason.VACATION}>Urlaub</SelectItem>
<SelectItem value={AbsenceReason.OTHER}>Sonstiges</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="absence-note">Bemerkung</Label>
<Textarea
id="absence-note"
name="note"
rows={3}
placeholder="Optional, z.B. voraussichtlich wieder da am Freitag."
/>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Abwesenheit melden
</Button>
</div>
</form>
) : (
<div className="flex min-h-72 flex-col items-center justify-center rounded-md border border-dashed bg-background/70 p-6 text-center">
<Baby className="h-10 w-10 text-muted-foreground" />
<p className="mt-4 font-medium">Noch keine Kinder hinterlegt</p>
<p className="mt-1 text-sm text-muted-foreground">
Lege zuerst deine Kinder im Profil an, dann kannst du sie hier
abmelden.
</p>
<Button asChild className="mt-4" variant="outline">
<Link href="/dashboard/profil">Zum Profil</Link>
</Button>
</div>
)}
<div className="rounded-md border bg-background/80 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="font-medium">Aktuelle & anstehende Meldungen</p>
<Badge variant="secondary">{absences.length}</Badge>
</div>
{absences.length === 0 ? (
<p className="text-sm text-muted-foreground">
Es sind keine aktuellen oder kommenden Abwesenheiten hinterlegt.
</p>
) : (
<div className="space-y-3">
{absences.map((absence) => (
<AbsenceRow
key={absence.id}
absence={absence}
disabled={isPending}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
</CardContent>
</Card>
);
}
function AbsenceRow({
absence,
disabled,
onDelete,
}: {
absence: AbsenceListItem;
disabled: boolean;
onDelete: (id: string) => void;
}) {
const range = useMemo(() => {
const start = new Date(`${absence.startDate}T00:00:00`);
const end = new Date(`${absence.endDate}T00:00:00`);
const startLabel = start.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
});
const endLabel = end.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
return absence.startDate === absence.endDate
? endLabel
: `${startLabel} - ${endLabel}`;
}, [absence.endDate, absence.startDate]);
return (
<div className="rounded-md border p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium">{absence.childName}</p>
<p className="mt-0.5 text-sm text-muted-foreground">{range}</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-destructive hover:text-destructive"
onClick={() => onDelete(absence.id)}
disabled={disabled}
aria-label="Abwesenheit stornieren"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<ReasonBadge reason={absence.reason} />
{absence.note && (
<span className="text-xs text-muted-foreground">{absence.note}</span>
)}
</div>
</div>
);
}
function ReasonBadge({ reason }: { reason: AbsenceReason }) {
if (reason === AbsenceReason.ILLNESS) {
return <Badge variant="destructive">Krank</Badge>;
}
if (reason === AbsenceReason.VACATION) {
return <Badge variant="warning">Urlaub</Badge>;
}
return <Badge variant="outline">Sonstiges</Badge>;
}
+52 -54
View File
@@ -15,10 +15,7 @@ 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,
) {
export async function triggerAlertAction(assignmentId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
@@ -36,6 +33,19 @@ export async function triggerAlertAction(
id: true,
firstName: true,
lastName: true,
family: {
select: {
users: {
where: { role: UserRole.ELTERN },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
},
},
},
},
},
},
},
@@ -50,7 +60,9 @@ export async function triggerAlertAction(
return { error: "Notdienst-Einteilung wurde nicht gefunden." };
}
if (!parentUserId) {
const parents = assignment.child.family.users;
if (parents.length === 0) {
return { error: "Kein Elternteil für diesen Notdienst hinterlegt." };
}
@@ -59,64 +71,50 @@ export async function triggerAlertAction(
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 alertJobs: { email: string; token: string }[] = [];
const emailResult = await sendAppEmail({
to: parentLink.user.email,
subject: `Dringender Notdienst-Alarm für ${dateLabel}`,
react: createElement(AlertEmail, {
date: dateLabel,
childName,
confirmLink: alertUrl,
await prisma.$transaction(
parents.map((parent) => {
const token = crypto.randomUUID();
alertJobs.push({ email: parent.email, token });
return prisma.notdienstAlert.create({
data: {
kitaId,
assignmentId,
parentUserId: parent.id,
triggeredById: session.user.id,
status: NotdienstAlertStatus.PENDING,
confirmationToken: token,
},
});
}),
});
);
for (const job of alertJobs) {
const emailResult = await sendAppEmail({
to: job.email,
subject: `Dringender Notdienst-Alarm für ${dateLabel}`,
react: createElement(AlertEmail, {
date: dateLabel,
childName,
confirmLink: `${BASE_URL}/alert/${job.token}`,
}),
});
if (!emailResult.success) {
revalidatePath("/dashboard");
return {
error: `Alarm wurde angelegt, aber mindestens eine E-Mail konnte nicht versendet werden: ${emailResult.error}`,
};
}
}
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);
@@ -0,0 +1,164 @@
import Link from "next/link";
import { addDays, format } from "date-fns";
import { de } from "date-fns/locale";
import { ArrowLeft, ArrowRight, CalendarDays, Stethoscope } from "lucide-react";
import { AbsenceReason } from "@prisma/client";
import { getDailyAbsences } from "@/actions/absences";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
export const metadata = { title: "Abwesenheiten · Kita-Planer" };
export default async function AdminAbwesenheitenPage({
searchParams,
}: {
searchParams: { date?: string };
}) {
const selectedDate = parseDateParam(searchParams.date);
const dateKey = format(selectedDate, "yyyy-MM-dd");
const result = await getDailyAbsences({ date: dateKey });
const absences = result.absences ?? [];
return (
<div className="min-h-full bg-slate-50 px-4 py-6 sm:px-8">
<div className="mx-auto max-w-5xl space-y-6">
<div className="flex flex-col gap-4 rounded-lg border bg-background p-5 shadow-sm lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Stethoscope className="h-4 w-4" />
Tagesübersicht
</div>
<h1 className="mt-2 text-3xl font-bold tracking-tight">
Heute fehlen ({absences.length} Kinder)
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{format(selectedDate, "EEEE, dd. MMMM yyyy", { locale: de })}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href={hrefForDate(addDays(selectedDate, -1))}>
<ArrowLeft className="h-4 w-4" />
Zurück
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/dashboard/admin/abwesenheiten">Heute</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={hrefForDate(addDays(selectedDate, 1))}>
Vor
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
<form className="flex items-center gap-2">
<Input
type="date"
name="date"
defaultValue={dateKey}
className="h-9 w-40"
/>
<Button type="submit" size="sm">
Anzeigen
</Button>
</form>
</div>
</div>
{result.error ? (
<Card className="border-destructive/30">
<CardContent className="p-6 text-sm text-destructive">
{result.error}
</CardContent>
</Card>
) : absences.length === 0 ? (
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-background p-8 text-center">
<div className="mx-auto max-w-md">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
<CalendarDays className="h-10 w-10" />
</div>
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
Keine Abwesenheiten
</h2>
<p className="mt-2 text-sm text-muted-foreground">
Für diesen Tag sind aktuell keine Kinder abgemeldet.
</p>
</div>
</div>
) : (
<div className="grid gap-3">
{absences.map((absence) => (
<Card key={absence.id} className="overflow-hidden">
<CardHeader className="flex flex-col gap-3 p-5 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle className="text-xl">
{absence.childName}
</CardTitle>
<CardDescription className="mt-1">
{absence.familyName}
</CardDescription>
</div>
<ReasonBadge reason={absence.reason} />
</CardHeader>
<CardContent className="grid gap-3 px-5 pb-5 sm:grid-cols-[180px_1fr]">
<div className="text-sm">
<p className="font-medium">Zeitraum</p>
<p className="text-muted-foreground">
{formatDateRange(absence.startDate, absence.endDate)}
</p>
</div>
<div className="text-sm">
<p className="font-medium">Notiz</p>
<p className="text-muted-foreground">
{absence.note || "Keine Bemerkung hinterlegt."}
</p>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
);
}
function parseDateParam(value: string | undefined) {
if (!value) return new Date();
const parsed = new Date(`${value}T00:00:00`);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
}
function hrefForDate(date: Date) {
return `/dashboard/admin/abwesenheiten?date=${format(date, "yyyy-MM-dd")}`;
}
function formatDateRange(startDate: string, endDate: string) {
const start = new Date(`${startDate}T00:00:00`);
const end = new Date(`${endDate}T00:00:00`);
const startLabel = format(start, "dd.MM.", { locale: de });
const endLabel = format(end, "dd.MM.yyyy", { locale: de });
return startDate === endDate ? endLabel : `${startLabel} - ${endLabel}`;
}
function ReasonBadge({ reason }: { reason: AbsenceReason }) {
if (reason === AbsenceReason.ILLNESS) {
return <Badge variant="destructive">Krank</Badge>;
}
if (reason === AbsenceReason.VACATION) {
return <Badge variant="warning">Urlaub</Badge>;
}
return <Badge variant="outline">Sonstiges</Badge>;
}
+247
View File
@@ -0,0 +1,247 @@
"use server";
import { createElement } from "react";
import { revalidatePath } from "next/cache";
import { DutyAssignmentStatus, UserRole } from "@prisma/client";
import { endOfWeek, format, startOfDay, startOfWeek } from "date-fns";
import { de } from "date-fns/locale";
import { z } from "zod";
import { DutyReminderEmail } from "@/emails/DutyReminderEmail";
import { requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma";
const dutyTypeSchema = z.object({
name: z.string().trim().min(2).max(60),
description: z.string().trim().max(240).optional(),
});
const assignmentSchema = z.object({
dutyTypeId: z.string().min(1),
familyId: z.string().nullable(),
startDate: z.string().min(1),
endDate: z.string().min(1),
});
async function requireDutyAdmin() {
const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]);
if (!session.user.kitaId) {
throw new Error("Kein Mandant zugeordnet.");
}
return { session, kitaId: session.user.kitaId };
}
function parseDateOnly(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new Error("Ungueltiges Datum.");
}
return startOfDay(date);
}
export async function createDutyType(formData: FormData) {
const { kitaId } = await requireDutyAdmin();
const parsed = dutyTypeSchema.safeParse({
name: formData.get("name"),
description: formData.get("description") || undefined,
});
if (!parsed.success) {
return;
}
try {
await prisma.dutyType.create({
data: {
kitaId,
name: parsed.data.name,
description: parsed.data.description,
},
});
revalidatePath("/dashboard/admin/dienste");
} catch (error) {
console.error("Fehler beim Erstellen des Diensttyps:", error);
}
}
export async function deleteDutyType(formData: FormData) {
const { kitaId } = await requireDutyAdmin();
const dutyTypeId = String(formData.get("dutyTypeId") ?? "");
if (!dutyTypeId) {
return;
}
await prisma.dutyType.deleteMany({
where: { id: dutyTypeId, kitaId },
});
revalidatePath("/dashboard/admin/dienste");
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard");
}
export async function upsertDutyAssignment(rawPayload: unknown) {
const { kitaId } = await requireDutyAdmin();
const parsed = assignmentSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungueltige Eingabedaten." };
}
const startDate = parseDateOnly(parsed.data.startDate);
const endDate = parseDateOnly(parsed.data.endDate);
if (endDate < startDate) {
return { error: "Das Enddatum liegt vor dem Startdatum." };
}
const dutyType = await prisma.dutyType.findFirst({
where: { id: parsed.data.dutyTypeId, kitaId },
select: { id: true },
});
if (!dutyType) {
return { error: "Diensttyp gehoert nicht zu dieser Kita." };
}
if (!parsed.data.familyId) {
await prisma.dutyAssignment.deleteMany({
where: {
kitaId,
dutyTypeId: parsed.data.dutyTypeId,
startDate,
},
});
revalidatePath("/dashboard/admin/dienste");
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard");
return { success: true };
}
const family = await prisma.family.findFirst({
where: { id: parsed.data.familyId, kitaId },
select: { id: true },
});
if (!family) {
return { error: "Familie gehoert nicht zu dieser Kita." };
}
await prisma.dutyAssignment.upsert({
where: {
kitaId_dutyTypeId_startDate: {
kitaId,
dutyTypeId: parsed.data.dutyTypeId,
startDate,
},
},
create: {
kitaId,
dutyTypeId: parsed.data.dutyTypeId,
familyId: parsed.data.familyId,
startDate,
endDate,
status: DutyAssignmentStatus.PLANNED,
},
update: {
familyId: parsed.data.familyId,
endDate,
status: DutyAssignmentStatus.PLANNED,
reminderSentAt: null,
},
});
revalidatePath("/dashboard/admin/dienste");
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard");
return { success: true };
}
export async function checkAndSendDutyReminders() {
const { kitaId } = await requireDutyAdmin();
const configError = getAppEmailConfigError();
if (configError) {
return { error: configError };
}
const now = new Date();
const weekStart = startOfWeek(now, { weekStartsOn: 1 });
const weekEnd = endOfWeek(now, { weekStartsOn: 1 });
const assignments = await prisma.dutyAssignment.findMany({
where: {
kitaId,
status: DutyAssignmentStatus.PLANNED,
reminderSentAt: null,
startDate: {
gte: startOfDay(weekStart),
lte: startOfDay(weekEnd),
},
},
select: {
id: true,
startDate: true,
endDate: true,
dutyType: {
select: {
name: true,
},
},
family: {
select: {
name: true,
users: {
where: {
emailVerifiedAt: { not: null },
},
select: {
email: true,
},
},
},
},
},
});
let sent = 0;
let skipped = 0;
const errors: string[] = [];
for (const assignment of assignments) {
const recipients = assignment.family.users.map((user) => user.email);
if (recipients.length === 0) {
skipped += 1;
continue;
}
const weekLabel = `${format(assignment.startDate, "dd.MM.", {
locale: de,
})} - ${format(assignment.endDate, "dd.MM.yyyy", { locale: de })}`;
const result = await sendAppEmail({
to: recipients,
subject: `Erinnerung: ${assignment.dutyType.name} diese Woche`,
react: createElement(DutyReminderEmail, {
familyName: assignment.family.name,
dutyName: assignment.dutyType.name,
weekLabel,
}),
});
if (!result.success) {
errors.push(`${assignment.family.name}: ${result.error}`);
continue;
}
await prisma.dutyAssignment.update({
where: { id: assignment.id },
data: { reminderSentAt: new Date() },
});
sent += recipients.length;
}
revalidatePath("/dashboard/admin/dienste");
return { success: true, sent, skipped, errors };
}
@@ -0,0 +1,211 @@
"use client";
import { useState, useTransition } from "react";
import { BellRing, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
checkAndSendDutyReminders,
upsertDutyAssignment,
} from "./actions";
const UNASSIGNED = "__unassigned__";
export type DutyPlanWeekDto = {
key: string;
label: string;
startDate: string;
endDate: string;
};
export type DutyPlanDutyTypeDto = {
id: string;
name: string;
description: string | null;
};
export type DutyPlanFamilyDto = {
id: string;
name: string;
};
export type DutyPlanAssignmentDto = {
id: string;
dutyTypeId: string;
familyId: string;
startDate: string;
};
export function DutyPlanner({
weeks,
dutyTypes,
families,
assignments,
}: {
weeks: DutyPlanWeekDto[];
dutyTypes: DutyPlanDutyTypeDto[];
families: DutyPlanFamilyDto[];
assignments: DutyPlanAssignmentDto[];
}) {
const [isPending, startTransition] = useTransition();
const [pendingCell, setPendingCell] = useState<string | null>(null);
const assignmentByCell = new Map(
assignments.map((assignment) => [
`${assignment.startDate}:${assignment.dutyTypeId}`,
assignment,
]),
);
function handleAssignmentChange(
week: DutyPlanWeekDto,
dutyTypeId: string,
value: string,
) {
const cellKey = `${week.startDate}:${dutyTypeId}`;
setPendingCell(cellKey);
startTransition(async () => {
const result = await upsertDutyAssignment({
dutyTypeId,
familyId: value === UNASSIGNED ? null : value,
startDate: week.startDate,
endDate: week.endDate,
});
if (result.error) {
toast.error(result.error);
} else {
toast.success(
value === UNASSIGNED
? "Zuweisung entfernt"
: "Dienstplan aktualisiert",
);
}
setPendingCell(null);
});
}
function sendReminders() {
startTransition(async () => {
const result = await checkAndSendDutyReminders();
if (result.error) {
toast.error(result.error);
return;
}
toast.success(
`Erinnerungen versendet: ${result.sent ?? 0}. Ohne Empfaenger: ${
result.skipped ?? 0
}.`,
);
if (result.errors?.length) {
toast.warning(`${result.errors.length} Erinnerung(en) fehlgeschlagen.`);
}
});
}
if (dutyTypes.length === 0) {
return (
<div className="rounded-md border border-dashed p-8 text-center">
<p className="text-sm font-medium">Noch keine Diensttypen</p>
<p className="mt-1 text-sm text-muted-foreground">
Lege zuerst einen Diensttyp an, danach erscheint hier das Planungsgrid.
</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={sendReminders}
disabled={isPending}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<BellRing className="h-4 w-4" />
)}
Reminder diese Woche senden
</Button>
</div>
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[760px] border-collapse text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="sticky left-0 z-10 w-56 bg-muted/95 px-4 py-3 text-left font-medium">
Woche
</th>
{dutyTypes.map((dutyType) => (
<th
key={dutyType.id}
className="min-w-56 px-4 py-3 text-left font-medium"
>
<div>{dutyType.name}</div>
{dutyType.description && (
<div className="mt-1 line-clamp-1 text-xs font-normal text-muted-foreground">
{dutyType.description}
</div>
)}
</th>
))}
</tr>
</thead>
<tbody>
{weeks.map((week) => (
<tr key={week.key} className="border-b last:border-b-0">
<td className="sticky left-0 z-10 bg-background px-4 py-3 font-medium">
{week.label}
</td>
{dutyTypes.map((dutyType) => {
const cellKey = `${week.startDate}:${dutyType.id}`;
const currentAssignment = assignmentByCell.get(cellKey);
return (
<td key={cellKey} className="px-4 py-3">
<Select
value={currentAssignment?.familyId ?? UNASSIGNED}
onValueChange={(value) =>
handleAssignmentChange(week, dutyType.id, value)
}
disabled={isPending && pendingCell === cellKey}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Familie wählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNASSIGNED}>
Nicht zugewiesen
</SelectItem>
{families.map((family) => (
<SelectItem key={family.id} value={family.id}>
{family.name}
</SelectItem>
))}
</SelectContent>
</Select>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+284
View File
@@ -0,0 +1,284 @@
import Link from "next/link";
import { addWeeks, format, getISOWeek, startOfDay, startOfWeek } from "date-fns";
import { de } from "date-fns/locale";
import { CalendarRange, ClipboardList, Plus, Trash2 } from "lucide-react";
import { UserRole } from "@prisma/client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { createDutyType, deleteDutyType } from "./actions";
import {
DutyPlanner,
type DutyPlanAssignmentDto,
type DutyPlanDutyTypeDto,
type DutyPlanFamilyDto,
type DutyPlanWeekDto,
} from "./duty-planner";
export const metadata = { title: "Dienstplan · Kita-Planer" };
const DEFAULT_WEEK_COUNT = 12;
const MAX_WEEK_COUNT = 104;
export default async function DienstePage({
searchParams,
}: {
searchParams: { start?: string; weeks?: string };
}) {
const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]);
if (!session.user.kitaId) {
throw new Error("Kein Mandant zugeordnet.");
}
const rangeStart = getRangeStart(searchParams.start);
const weekCount = getWeekCount(searchParams.weeks);
const weeks = buildWeeks(rangeStart, weekCount);
const rangeEnd = startOfDay(new Date(weeks[weeks.length - 1].endDate));
const [dutyTypes, families, assignments] = await Promise.all([
prisma.dutyType.findMany({
where: { kitaId: session.user.kitaId },
select: {
id: true,
name: true,
description: true,
},
orderBy: { name: "asc" },
}),
prisma.family.findMany({
where: { kitaId: session.user.kitaId },
select: {
id: true,
name: true,
},
orderBy: { name: "asc" },
}),
prisma.dutyAssignment.findMany({
where: {
kitaId: session.user.kitaId,
startDate: {
gte: rangeStart,
lte: rangeEnd,
},
},
select: {
id: true,
dutyTypeId: true,
familyId: true,
startDate: true,
},
}),
]);
const dutyTypeDtos: DutyPlanDutyTypeDto[] = dutyTypes;
const familyDtos: DutyPlanFamilyDto[] = families;
const assignmentDtos: DutyPlanAssignmentDto[] = assignments.map((assignment) => ({
id: assignment.id,
dutyTypeId: assignment.dutyTypeId,
familyId: assignment.familyId,
startDate: toDateKey(assignment.startDate),
}));
return (
<div className="space-y-6 p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<ClipboardList className="h-4 w-4" />
Admin
</div>
<h1 className="mt-2 text-2xl font-bold tracking-tight">
Dienstplan-Manager
</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Plane Dienste pro Kalenderwoche und Haushalt. Jede Änderung wird
direkt gespeichert.
</p>
</div>
<Card className="w-full lg:max-w-xl">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<CalendarRange className="h-4 w-4" />
Zeitraum
</CardTitle>
<CardDescription>
Du kannst beliebig weit voraus planen, jeweils in handlichen
Ausschnitten.
</CardDescription>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="grid gap-1.5">
<Label htmlFor="start">Startwoche</Label>
<Input
id="start"
name="start"
type="date"
defaultValue={toDateInputValue(rangeStart)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="weeks">Wochen</Label>
<Input
id="weeks"
name="weeks"
type="number"
min={1}
max={MAX_WEEK_COUNT}
defaultValue={weekCount}
/>
</div>
<Button type="submit">Anzeigen</Button>
<Button asChild type="button" variant="outline">
<Link
href={`/dashboard/admin/dienste?start=${toDateInputValue(
rangeStart,
)}&weeks=${Math.min(weekCount + 12, MAX_WEEK_COUNT)}`}
>
Weitere Wochen laden
</Link>
</Button>
</form>
</CardContent>
</Card>
</div>
<div className="grid gap-6 xl:grid-cols-[340px_1fr]">
<Card>
<CardHeader>
<CardTitle className="text-base">Diensttypen</CardTitle>
<CardDescription>
Beispiele sind Wäschedienst, Einkauf oder Gartendienst.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<form action={createDutyType} className="space-y-3">
<div className="grid gap-1.5">
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" placeholder="Wäschedienst" />
</div>
<div className="grid gap-1.5">
<Label htmlFor="description">Beschreibung</Label>
<Textarea
id="description"
name="description"
placeholder="Kurz beschreiben, was zu tun ist."
rows={3}
/>
</div>
<Button type="submit" size="sm">
<Plus className="h-4 w-4" />
Diensttyp anlegen
</Button>
</form>
<div className="space-y-2">
{dutyTypes.length === 0 ? (
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
Noch keine Diensttypen angelegt.
</p>
) : (
dutyTypes.map((dutyType) => (
<div
key={dutyType.id}
className="flex items-start justify-between gap-3 rounded-md border p-3"
>
<div className="min-w-0">
<p className="font-medium">{dutyType.name}</p>
{dutyType.description && (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{dutyType.description}
</p>
)}
</div>
<form action={deleteDutyType}>
<input
type="hidden"
name="dutyTypeId"
value={dutyType.id}
/>
<Button
type="submit"
variant="ghost"
size="icon"
aria-label={`${dutyType.name} löschen`}
>
<Trash2 className="h-4 w-4" />
</Button>
</form>
</div>
))
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Planung</CardTitle>
<CardDescription>
{weeks[0].label} bis {weeks[weeks.length - 1].label}
</CardDescription>
</CardHeader>
<CardContent>
<DutyPlanner
weeks={weeks}
dutyTypes={dutyTypeDtos}
families={familyDtos}
assignments={assignmentDtos}
/>
</CardContent>
</Card>
</div>
</div>
);
}
function getRangeStart(value: string | undefined) {
const parsed = value ? new Date(value) : new Date();
const safeDate = Number.isNaN(parsed.getTime()) ? new Date() : parsed;
return startOfDay(startOfWeek(safeDate, { weekStartsOn: 1 }));
}
function getWeekCount(value: string | undefined) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return DEFAULT_WEEK_COUNT;
return Math.min(Math.max(Math.trunc(parsed), 1), MAX_WEEK_COUNT);
}
function buildWeeks(start: Date, count: number): DutyPlanWeekDto[] {
return Array.from({ length: count }, (_, index) => {
const weekStart = addWeeks(start, index);
const weekEnd = addWeeks(start, index);
weekEnd.setDate(weekStart.getDate() + 6);
return {
key: toDateKey(weekStart),
label: `KW ${getISOWeek(weekStart)}: ${format(weekStart, "dd.MM.", {
locale: de,
})} - ${format(weekEnd, "dd.MM.", { locale: de })}`,
startDate: toDateKey(weekStart),
endDate: toDateKey(weekEnd),
};
});
}
function toDateKey(date: Date) {
return format(date, "yyyy-MM-dd");
}
function toDateInputValue(date: Date) {
return format(date, "yyyy-MM-dd");
}
+142
View File
@@ -0,0 +1,142 @@
"use client";
import { useState, useTransition } from "react";
import { Loader2, Send, UploadCloud } from "lucide-react";
import { toast } from "sonner";
import { createAnnouncement } from "@/actions/announcements";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/markdown-content";
export function NewsForm() {
const [isPending, startTransition] = useTransition();
const [sendEmail, setSendEmail] = useState(false);
const [content, setContent] = useState("");
function handleSubmit(formData: FormData) {
startTransition(async () => {
if (sendEmail) {
formData.set("sendEmail", "on");
}
const result = await createAnnouncement(formData);
if (result.error) {
toast.error(result.error);
return;
}
if (result.warning) {
toast.warning(result.warning);
} else {
toast.success("Ankündigung veröffentlicht.");
}
});
}
return (
<form action={handleSubmit} className="grid gap-5">
<div className="grid gap-2">
<Label htmlFor="news-title">Titel</Label>
<Input
id="news-title"
name="title"
placeholder="Sommerfest: Aufbau und Mitbringliste"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="news-content">Text</Label>
<Textarea
id="news-content"
name="content"
rows={10}
value={content}
onChange={(event) => setContent(event.target.value)}
placeholder={"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."}
required
/>
<p className="text-xs text-muted-foreground">
Markdown ist aktiv. HTML wird nicht interpretiert.
</p>
</div>
{content.trim() && (
<div className="rounded-md border bg-muted/30 p-4 text-sm">
<p className="mb-3 text-xs font-semibold uppercase text-muted-foreground">
Vorschau
</p>
<MarkdownContent content={content} />
</div>
)}
<div className="grid gap-2">
<Label htmlFor="attachments">Anhänge</Label>
<div className="rounded-md border border-dashed bg-background p-4">
<Input
id="attachments"
name="attachments"
type="file"
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
multiple
/>
<p className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
<UploadCloud className="h-3.5 w-3.5" />
PDF, JPG und PNG bis 10 MB pro Datei.
</p>
</div>
</div>
<div className="rounded-md border p-4">
<div className="flex items-center gap-2">
<Checkbox
id="sendEmail"
checked={sendEmail}
onCheckedChange={(checked) => setSendEmail(checked === true)}
/>
<Label htmlFor="sendEmail" className="cursor-pointer">
Auch als E-Mail versenden
</Label>
</div>
{sendEmail && (
<div className="mt-4 grid gap-2">
<Label>Empfänger</Label>
<Select name="audience" defaultValue="ELTERN">
<SelectTrigger>
<SelectValue placeholder="Empfänger auswählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ELTERN">Eltern</SelectItem>
<SelectItem value="ERZIEHER">ErzieherInnen</SelectItem>
<SelectItem value="ALLE">Alle</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
Veröffentlichen
</Button>
</div>
</form>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { FileText, Megaphone } from "lucide-react";
import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { NewsForm } from "./news-form";
export const metadata = { title: "Schwarzes Brett · Kita-Planer" };
export default async function AdminNewsPage() {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
throw new Error("Kein Mandant zugeordnet.");
}
const announcements = await prisma.announcement.findMany({
where: { kitaId },
select: {
id: true,
title: true,
createdAt: true,
attachments: {
select: {
id: true,
fileName: true,
},
},
author: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: { createdAt: "desc" },
take: 8,
});
return (
<div className="px-8 py-8">
<div className="mb-8">
<Badge className="mb-3 bg-emerald-100 text-emerald-800 hover:bg-emerald-100">
Offizielle Kita-Kommunikation
</Badge>
<h1 className="flex items-center gap-3 text-2xl font-semibold tracking-tight">
<Megaphone className="h-6 w-6 text-emerald-700" />
Digitales Schwarzes Brett
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
Veröffentliche verbindliche Ankündigungen für deine Kita. Anhänge
werden geschützt gespeichert und nur für eingeloggte Nutzer derselben
Kita ausgeliefert.
</p>
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
<Card>
<CardHeader>
<CardTitle>Neue Ankündigung</CardTitle>
<CardDescription>
Markdown wird sicher gerendert; PDF, JPG und PNG können als
geschützte Anhänge hinzugefügt werden.
</CardDescription>
</CardHeader>
<CardContent>
<NewsForm />
</CardContent>
</Card>
<Card className="h-fit">
<CardHeader>
<CardTitle className="text-base">Zuletzt veröffentlicht</CardTitle>
<CardDescription>
Die neuesten Meldungen auf dem Schwarzen Brett.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
{announcements.length === 0 ? (
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
Noch keine Ankündigungen veröffentlicht.
</p>
) : (
announcements.map((announcement) => (
<div
key={announcement.id}
className="rounded-md border bg-background p-3"
>
<div className="mb-2 flex items-start justify-between gap-2">
<p className="font-medium leading-snug">
{announcement.title}
</p>
{announcement.attachments.length > 0 && (
<Badge variant="secondary" className="shrink-0">
<FileText className="h-3 w-3" />
{announcement.attachments.length}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{format(announcement.createdAt, "dd.MM.yyyy HH:mm", {
locale: de,
})}{" "}
· {announcement.author.firstName}{" "}
{announcement.author.lastName}
</p>
</div>
))
)}
</CardContent>
</Card>
</div>
</div>
);
}
+221 -69
View File
@@ -1,101 +1,253 @@
import { Baby, Contact, Mail, Phone, ShieldCheck, UsersRound } from "lucide-react";
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";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export const metadata = { title: "Adressbuch · Kita-Planer" };
type DirectoryFamilyDto = {
id: string;
name: string;
children: {
id: string;
firstName: string;
lastName: string;
}[];
parents: {
id: string;
firstName: string;
lastName: string;
hasDirectoryOptIn: boolean;
email?: string;
phone?: string;
duties: {
id: string;
name: string;
}[];
}[];
};
export default async function AdressbuchPage() {
const session = await requireKitaSession();
// Fetch only users who opted in to the directory
const users = await prisma.user.findMany({
const familyRows = await prisma.family.findMany({
where: {
kitaId: session.user.kitaId,
users: {
some: {
directoryOptInAt: { not: null },
},
},
},
select: {
id: true,
name: true,
children: {
select: {
id: true,
firstName: true,
lastName: true,
},
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
},
users: {
select: {
id: true,
firstName: true,
lastName: true,
directoryOptInAt: true,
dutyAssignments: {
select: {
id: true,
duty: {
select: {
name: true,
},
},
},
},
},
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
},
},
orderBy: { name: "asc" },
});
const visibleContactRows = await prisma.user.findMany({
where: {
kitaId: session.user.kitaId,
directoryOptInAt: { not: null },
familyId: { in: familyRows.map((family) => family.id) },
},
include: {
childLinks: {
include: { child: true },
},
dutyAssignments: {
include: { duty: true },
},
select: {
id: true,
email: true,
phone: true,
},
orderBy: { lastName: "asc" },
});
const visibleContactsByUserId = new Map(
visibleContactRows.map((user) => [user.id, user]),
);
const families: DirectoryFamilyDto[] = familyRows.map((family) => ({
id: family.id,
name: family.name,
children: family.children,
parents: family.users.map((user) => {
const hasDirectoryOptIn = !!user.directoryOptInAt;
const contact = visibleContactsByUserId.get(user.id);
return {
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
hasDirectoryOptIn,
email: contact?.email,
phone: contact?.phone ?? undefined,
duties: user.dutyAssignments.map((assignment) => ({
id: assignment.id,
name: assignment.duty.name,
})),
};
}),
}));
return (
<div className="flex h-full flex-col gap-6 p-6">
<div>
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Adressbuch</h1>
<p className="text-muted-foreground">
Kontaktinformationen aller Eltern, die der Freigabe zugestimmt haben.
<p className="max-w-2xl text-sm text-muted-foreground">
Haushalte und Kontaktdaten, die explizit fuer das interne
Kita-Adressbuch freigegeben wurden.
</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.
{families.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-background p-10 text-center">
<Contact className="mb-4 h-10 w-10 text-muted-foreground" />
<h3 className="text-lg font-semibold">Keine freigegebenen Kontakte</h3>
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
Sobald mindestens ein Elternteil eines Haushalts zustimmt,
erscheint die Familie hier.
</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 className="grid gap-4 lg:grid-cols-2">
{families.map((family) => (
<Card key={family.id} className="overflow-hidden">
<CardHeader className="border-b bg-muted/30 pb-4">
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="flex items-center gap-2 text-lg">
<UsersRound className="h-4 w-4 text-primary" />
{family.name}
</CardTitle>
<CardDescription className="mt-1">
{family.parents.filter((user) => user.hasDirectoryOptIn).length}{" "}
von {family.parents.length} Elternteilen sichtbar
</CardDescription>
</div>
<Badge variant="success">Freigegeben</Badge>
</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>
)}
</CardHeader>
{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}
<CardContent className="grid gap-5 pt-5">
<section className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Baby className="h-3.5 w-3.5" />
Kinder
</div>
{family.children.length === 0 ? (
<p className="text-sm text-muted-foreground">
Keine Kinder hinterlegt.
</p>
) : (
<div className="flex flex-wrap gap-1.5">
{family.children.map((child) => (
<Badge key={child.id} variant="secondary">
{child.firstName} {child.lastName}
</Badge>
))}
</div>
)}
</section>
<section className="grid gap-3">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<ShieldCheck className="h-3.5 w-3.5" />
Kontakte
</div>
)}
<div className="grid gap-3">
{family.parents.map((user) => {
return (
<div
key={user.id}
className="rounded-md border bg-background p-3"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-medium">
{user.firstName} {user.lastName}
</p>
{!user.hasDirectoryOptIn && (
<p className="mt-0.5 text-xs text-muted-foreground">
Kontakt nicht freigegeben.
</p>
)}
</div>
{user.hasDirectoryOptIn ? (
<Badge variant="outline">Opt-in</Badge>
) : null}
</div>
{user.hasDirectoryOptIn && (
<div className="mt-3 grid gap-2 text-sm text-muted-foreground">
{user.email && (
<a
href={`mailto:${user.email}`}
className="flex min-w-0 items-center gap-2 hover:text-primary"
>
<Mail className="h-4 w-4 shrink-0" />
<span className="truncate">{user.email}</span>
</a>
)}
{user.phone && (
<a
href={`tel:${user.phone}`}
className="flex items-center gap-2 hover:text-primary"
>
<Phone className="h-4 w-4 shrink-0" />
{user.phone}
</a>
)}
</div>
)}
{user.duties.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5 border-t pt-3">
{user.duties.map((duty) => (
<Badge
key={duty.id}
variant="outline"
className="bg-primary/5 text-primary"
>
{duty.name}
</Badge>
))}
</div>
)}
</div>
);
})}
</div>
</section>
</CardContent>
</Card>
))}
+1 -3
View File
@@ -9,10 +9,8 @@ import { triggerAlertAction } from "./actions";
export function AlertButton({
assignmentId,
parentUserId,
}: {
assignmentId: string;
parentUserId: string;
}) {
const [isPending, startTransition] = useTransition();
@@ -20,7 +18,7 @@ export function AlertButton({
if (!confirm("Bist du sicher? Dies löst den Notdienst-Alarm aus.")) return;
startTransition(async () => {
const result = await triggerAlertAction(assignmentId, parentUserId);
const result = await triggerAlertAction(assignmentId);
if ("error" in result && result.error) {
toast.error(result.error);
} else {
@@ -1,7 +1,6 @@
"use client";
import { useState, useTransition } from "react";
import { Educator } from "@prisma/client";
import { Plus, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
@@ -30,9 +29,16 @@ import {
import { createEducator, updateEducator, deleteEducator } from "../actions";
export function ErzieherList({ educators }: { educators: Educator[] }) {
type EducatorDto = {
id: string;
firstName: string;
lastName: string;
active: boolean;
};
export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
const [openCreate, setOpenCreate] = useState(false);
const [editingEducator, setEditingEducator] = useState<Educator | null>(null);
const [editingEducator, setEditingEducator] = useState<EducatorDto | null>(null);
const [isPending, startTransition] = useTransition();
const handleCreate = (formData: FormData) => {
+17 -5
View File
@@ -8,13 +8,17 @@ 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"),
firstName: z.string().min(1, "Vorname ist erforderlich").max(100).trim(),
lastName: z.string().min(1, "Nachname ist erforderlich").max(100).trim(),
active: z.boolean().default(true),
});
export async function createEducator(rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = educatorSchema.safeParse(rawPayload);
if (!parsed.success) {
@@ -24,7 +28,7 @@ export async function createEducator(rawPayload: unknown) {
try {
await prisma.educator.create({
data: {
kitaId: session.user.kitaId!,
kitaId,
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
active: parsed.data.active,
@@ -41,6 +45,10 @@ export async function createEducator(rawPayload: unknown) {
export async function updateEducator(id: string, rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = educatorSchema.safeParse(rawPayload);
if (!parsed.success) {
@@ -51,7 +59,7 @@ export async function updateEducator(id: string, rawPayload: unknown) {
await prisma.educator.update({
where: {
id,
kitaId: session.user.kitaId!,
kitaId,
},
data: {
firstName: parsed.data.firstName,
@@ -70,12 +78,16 @@ export async function updateEducator(id: string, rawPayload: unknown) {
export async function deleteEducator(id: string) {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try {
await prisma.educator.delete({
where: {
id,
kitaId: session.user.kitaId!,
kitaId,
},
});
+18 -5
View File
@@ -1,3 +1,4 @@
import { GraduationCap } from "lucide-react";
import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
@@ -11,16 +12,28 @@ export default async function ErzieherPage() {
const educators = await prisma.educator.findMany({
where: { kitaId: session.user.kitaId! },
select: {
id: true,
firstName: true,
lastName: true,
active: true,
},
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 className="flex items-start justify-between gap-4">
<div>
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<GraduationCap className="h-6 w-6 text-primary" />
ErzieherInnen-Verwaltung
</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Stammdaten des Kita-Personals für interne Planung und spätere
Notdienst-Alarmierung.
</p>
</div>
</div>
<ErzieherList educators={educators} />
+363 -86
View File
@@ -2,30 +2,46 @@
import crypto from "crypto";
import { createElement } from "react";
import { revalidatePath } from "next/cache";
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 { requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma";
// =====================================================================
// /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 INVITE_TOKEN_TTL_DAYS = 7;
const BASE_URL =
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const parentSchema = z.object({
firstName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
lastName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
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(),
});
const parentInputSchema = z.object({
firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
email: z
.string()
.email("Bitte eine gültige E-Mail-Adresse angeben.")
.toLowerCase()
.trim(),
});
const addFamilySchema = z.object({
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
parent1FirstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
parent1LastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
parent1Email: z
.string()
.email("Bitte eine gültige E-Mail-Adresse angeben.")
.toLowerCase()
.trim(),
parent2FirstName: z.string().max(100).trim().optional(),
parent2LastName: z.string().max(100).trim().optional(),
parent2Email: z.string().trim().optional(),
childCount: z.coerce
.number()
.int()
@@ -33,65 +49,139 @@ const parentSchema = z.object({
.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(),
const updateFamilySchema = z.object({
familyId: z.string().min(1),
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
parents: z.array(
parentInputSchema.extend({
id: z.string().min(1),
}),
).min(1).max(2),
newParents: z.array(parentInputSchema).max(1).default([]),
children: z
.array(
childSchema.extend({
id: z.string().min(1),
}),
)
.max(20),
newChildren: z.array(childSchema).max(10).default([]),
removedChildIds: z.array(z.string().min(1)).max(20).default([]),
});
export type AddFamilyState = {
errors?: {
firstName?: string[];
lastName?: string[];
email?: string[];
familyName?: string[];
parent1FirstName?: string[];
parent1LastName?: string[];
parent1Email?: string[];
parent2FirstName?: string[];
parent2LastName?: string[];
parent2Email?: 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";
type InviteJob = {
to: string;
parentName: string;
inviteUrl: string;
};
function optionalParentFromForm(data: z.infer<typeof addFamilySchema>) {
const firstName = data.parent2FirstName?.trim() ?? "";
const lastName = data.parent2LastName?.trim() ?? "";
const email = data.parent2Email?.trim() ?? "";
if (!firstName && !lastName && !email) {
return { ok: true as const, parent: null };
}
const parsed = parentInputSchema.safeParse({ firstName, lastName, email });
if (!parsed.success) {
return {
ok: false as const,
errors: {
parent2FirstName: parsed.error.flatten().fieldErrors.firstName,
parent2LastName: parsed.error.flatten().fieldErrors.lastName,
parent2Email: parsed.error.flatten().fieldErrors.email,
},
};
}
return { ok: true as const, parent: parsed.data };
}
function createInviteToken() {
const token = crypto.randomUUID();
const expires = new Date(
Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000,
);
return { token, expires, inviteUrl: `${BASE_URL}/invite/${token}` };
}
async function sendInviteJobs(kitaName: string, jobs: InviteJob[]) {
for (const job of jobs) {
const result = await sendAppEmail({
to: job.to,
subject: `Einladung zu ${kitaName} im Kita-Planer`,
react: createElement(InviteEmail, {
parentName: job.parentName,
kitaName,
inviteLink: job.inviteUrl,
}),
});
if (!result.success) {
return result.error;
}
}
return null;
}
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]);
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
// ── 2. Parent-Felder validieren ────────────────────────────────────
const parsedParent = parentSchema.safeParse(Object.fromEntries(formData));
if (!parsedParent.success) {
return { errors: parsedParent.error.flatten().fieldErrors };
if (!kitaId) {
return { errors: { _form: ["Kein Mandant zugeordnet."] } };
}
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 parsedFamily = addFamilySchema.safeParse(Object.fromEntries(formData));
if (!parsedFamily.success) {
return { errors: parsedFamily.error.flatten().fieldErrors };
}
const optionalParent = optionalParentFromForm(parsedFamily.data);
if (!optionalParent.ok) {
return { errors: optionalParent.errors };
}
const childrenRaw: z.infer<typeof childSchema>[] = [];
for (let i = 0; i < parsedFamily.data.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(", ")}`] },
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] } };
@@ -106,54 +196,68 @@ export async function addFamilyAction(
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,
);
const parents = [
{
firstName: parsedFamily.data.parent1FirstName,
lastName: parsedFamily.data.parent1LastName,
email: parsedFamily.data.parent1Email,
},
...(optionalParent.parent ? [optionalParent.parent] : []),
];
const emailSet = new Set(parents.map((parent) => parent.email));
if (emailSet.size !== parents.length) {
return {
errors: {
_form: ["Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein."],
},
};
}
const inviteJobs: InviteJob[] = [];
try {
await prisma.$transaction(async (tx) => {
// 4a. Elternteil anlegen (kein Passwort → leerer passwordHash)
const parent = await tx.user.create({
const family = await tx.family.create({
data: {
email,
firstName,
lastName,
passwordHash: "", // wird beim Invite-Einlösen gesetzt
role: UserRole.ELTERN,
kitaId,
name: parsedFamily.data.familyName,
},
});
// 4b. Kinder anlegen + mit Elternteil verknüpfen
for (const child of childrenRaw) {
const createdChild = await tx.child.create({
for (const parent of parents) {
const createdParent = await tx.user.create({
data: {
kitaId,
firstName: child.firstName,
lastName: child.lastName,
familyId: family.id,
email: parent.email,
firstName: parent.firstName,
lastName: parent.lastName,
passwordHash: "",
role: UserRole.ELTERN,
},
});
await tx.childParent.create({
const invite = createInviteToken();
inviteJobs.push({
to: parent.email,
parentName: `${parent.firstName} ${parent.lastName}`,
inviteUrl: invite.inviteUrl,
});
await tx.verificationToken.create({
data: {
kitaId,
childId: createdChild.id,
userId: parent.id,
identifier: createdParent.id,
token: invite.token,
expires: invite.expires,
},
});
}
// 4c. Einladungs-Token erstellen
// identifier = userId (kein PII im Token selbst)
await tx.verificationToken.create({
data: {
identifier: parent.id,
token,
expires,
},
await tx.child.createMany({
data: childrenRaw.map((child) => ({
kitaId,
familyId: family.id,
firstName: child.firstName,
lastName: child.lastName,
})),
});
});
} catch (err) {
@@ -163,32 +267,205 @@ export async function addFamilyAction(
) {
return {
errors: {
email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."],
_form: ["Mindestens eine E-Mail-Adresse existiert bereits."],
},
};
}
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) {
const emailError = await sendInviteJobs(kita.name, inviteJobs);
if (emailError) {
return {
errors: {
_form: [
`Familie wurde angelegt, aber die Einladung konnte nicht versendet werden: ${emailResult.error}`,
`Familie wurde angelegt, aber mindestens eine Einladung konnte nicht versendet werden: ${emailError}`,
],
},
};
}
revalidatePath("/dashboard/families");
revalidatePath("/dashboard");
return { success: true };
}
export async function updateFamilyAction(rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = updateFamilySchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const {
familyId,
familyName,
parents,
newParents,
children,
newChildren,
removedChildIds,
} = parsed.data;
const parentEmails = [...parents, ...newParents].map((parent) => parent.email);
if (new Set(parentEmails).size !== parentEmails.length) {
return { error: "Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein." };
}
const removedChildIdSet = new Set(removedChildIds);
const activeChildren = children.filter((child) => !removedChildIdSet.has(child.id));
if (activeChildren.length + newChildren.length === 0) {
return { error: "Eine Familie benötigt mindestens ein Kind." };
}
if (newParents.length > 0) {
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { error: mailConfigError };
}
}
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
});
if (!kita) {
return { error: "Kita wurde nicht gefunden." };
}
const inviteJobs: InviteJob[] = [];
try {
const existingFamily = await prisma.family.findFirst({
where: { id: familyId, kitaId },
select: {
id: true,
users: { select: { id: true } },
children: { select: { id: true } },
},
});
if (!existingFamily) {
return { error: "Familie wurde nicht gefunden." };
}
const existingParentIds = new Set(existingFamily.users.map((user) => user.id));
const existingChildIds = new Set(existingFamily.children.map((child) => child.id));
if (!parents.every((parent) => existingParentIds.has(parent.id))) {
return { error: "Mindestens ein Elternteil gehört nicht zu dieser Familie." };
}
if (!children.every((child) => existingChildIds.has(child.id))) {
return { error: "Mindestens ein Kind gehört nicht zu dieser Familie." };
}
if (!removedChildIds.every((childId) => existingChildIds.has(childId))) {
return { error: "Mindestens ein Kind gehört nicht zu dieser Familie." };
}
if (existingParentIds.size + newParents.length > 2) {
return { error: "Pro Familie sind maximal zwei Elternteile vorgesehen." };
}
await prisma.$transaction(async (tx) => {
await tx.family.update({
where: { id: familyId },
data: { name: familyName },
});
for (const parent of parents) {
await tx.user.update({
where: { id: parent.id },
data: {
firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
},
});
}
for (const parent of newParents) {
const createdParent = await tx.user.create({
data: {
kitaId,
familyId,
email: parent.email,
firstName: parent.firstName,
lastName: parent.lastName,
passwordHash: "",
role: UserRole.ELTERN,
},
});
const invite = createInviteToken();
inviteJobs.push({
to: parent.email,
parentName: `${parent.firstName} ${parent.lastName}`,
inviteUrl: invite.inviteUrl,
});
await tx.verificationToken.create({
data: {
identifier: createdParent.id,
token: invite.token,
expires: invite.expires,
},
});
}
for (const child of activeChildren) {
await tx.child.update({
where: { id: child.id },
data: {
firstName: child.firstName,
lastName: child.lastName,
},
});
}
for (const childId of removedChildIds) {
await tx.child.delete({ where: { id: childId } });
}
for (const child of newChildren) {
await tx.child.create({
data: {
kitaId,
familyId,
firstName: child.firstName,
lastName: child.lastName,
},
});
}
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2002"
) {
return { error: "Mindestens eine E-Mail-Adresse existiert bereits." };
}
console.error("Fehler beim Aktualisieren der Familie:", error);
return { error: "Familie konnte nicht aktualisiert werden." };
}
const emailError = await sendInviteJobs(kita.name, inviteJobs);
if (emailError) {
return {
error: `Familie wurde aktualisiert, aber mindestens eine Einladung konnte nicht versendet werden: ${emailError}`,
};
}
revalidatePath("/dashboard/families");
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/notdienst");
revalidatePath("/dashboard");
return { success: true };
}
@@ -25,6 +25,7 @@ const initialState: AddFamilyState = {};
// Verwaltet:
// • Dialog-Open/Close-State
// • Dynamische Kinderliste (min. 1, max. 10)
// • Ein oder zwei Elternteile pro Haushalt
// • useActionState → Server-Action-Fehler anzeigen
// • Bei Erfolg (state.success) Dialog automatisch schließen
// =====================================================================
@@ -62,8 +63,8 @@ export function AddFamilyDialog() {
<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.
Lege einen Haushalt mit Elternteilen und Kindern an. Die
Elternteile erhalten Einladungslinks per E-Mail.
</DialogDescription>
</DialogHeader>
@@ -71,34 +72,77 @@ export function AddFamilyDialog() {
{/* Anzahl Kinder als verstecktes Feld für die Server Action */}
<input type="hidden" name="childCount" value={childCount} />
{/* ── Elternteil ─────────────────────────────────────────── */}
<FormField
id="familyName"
name="familyName"
label="Familienname"
placeholder="Familie Schmidt"
error={state.errors?.familyName?.[0]}
/>
{/* ── Elternteile ────────────────────────────────────────── */}
<fieldset className="space-y-4">
<legend className="text-sm font-semibold">Elternteil</legend>
<legend className="text-sm font-semibold">Elternteil 1</legend>
<div className="grid grid-cols-2 gap-3">
<FormField
id="firstName"
name="firstName"
id="parent1FirstName"
name="parent1FirstName"
label="Vorname"
autoComplete="given-name"
error={state.errors?.firstName?.[0]}
error={state.errors?.parent1FirstName?.[0]}
/>
<FormField
id="lastName"
name="lastName"
id="parent1LastName"
name="parent1LastName"
label="Nachname"
autoComplete="family-name"
error={state.errors?.lastName?.[0]}
error={state.errors?.parent1LastName?.[0]}
/>
</div>
<FormField
id="email"
name="email"
id="parent1Email"
name="parent1Email"
type="email"
label="E-Mail-Adresse"
autoComplete="email"
error={state.errors?.email?.[0]}
error={state.errors?.parent1Email?.[0]}
/>
</fieldset>
<fieldset className="space-y-4">
<legend className="text-sm font-semibold">
Elternteil 2 <span className="font-normal text-muted-foreground">(optional)</span>
</legend>
<div className="grid grid-cols-2 gap-3">
<FormField
id="parent2FirstName"
name="parent2FirstName"
label="Vorname"
autoComplete="given-name"
required={false}
error={state.errors?.parent2FirstName?.[0]}
/>
<FormField
id="parent2LastName"
name="parent2LastName"
label="Nachname"
autoComplete="family-name"
required={false}
error={state.errors?.parent2LastName?.[0]}
/>
</div>
<FormField
id="parent2Email"
name="parent2Email"
type="email"
label="E-Mail-Adresse"
autoComplete="email"
required={false}
error={state.errors?.parent2Email?.[0]}
/>
</fieldset>
@@ -199,6 +243,8 @@ function FormField({
label,
type = "text",
autoComplete,
placeholder,
required = true,
error,
}: {
id: string;
@@ -206,6 +252,8 @@ function FormField({
label: string;
type?: string;
autoComplete?: string;
placeholder?: string;
required?: boolean;
error?: string;
}) {
return (
@@ -216,7 +264,8 @@ function FormField({
name={name}
type={type}
autoComplete={autoComplete}
required
placeholder={placeholder}
required={required}
aria-invalid={!!error}
/>
{error && <p className="text-xs text-destructive">{error}</p>}
+40 -8
View File
@@ -13,7 +13,12 @@ const dutySchema = z.object({
});
export async function createDuty(rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = dutySchema.safeParse(rawPayload);
if (!parsed.success) {
@@ -23,7 +28,7 @@ export async function createDuty(rawPayload: unknown) {
try {
await prisma.parentDuty.create({
data: {
kitaId: session.user.kitaId!,
kitaId,
name: parsed.data.name,
description: parsed.data.description,
},
@@ -38,13 +43,17 @@ export async function createDuty(rawPayload: unknown) {
}
export async function deleteDuty(dutyId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try {
await prisma.parentDuty.delete({
where: {
id: dutyId,
kitaId: session.user.kitaId!,
kitaId,
},
});
revalidatePath("/dashboard/families");
@@ -57,9 +66,28 @@ export async function deleteDuty(dutyId: string) {
}
export async function assignDuty(userId: string, dutyId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try {
const [user, duty] = await Promise.all([
prisma.user.findFirst({
where: { id: userId, kitaId },
select: { id: true },
}),
prisma.parentDuty.findFirst({
where: { id: dutyId, kitaId },
select: { id: true },
}),
]);
if (!user || !duty) {
return { error: "Nutzer oder Amt gehört nicht zu dieser Kita." };
}
// Check if assignment already exists
const existing = await prisma.parentDutyAssignment.findUnique({
where: {
@@ -73,7 +101,7 @@ export async function assignDuty(userId: string, dutyId: string) {
await prisma.parentDutyAssignment.create({
data: {
kitaId: session.user.kitaId!,
kitaId,
userId: userId,
dutyId: dutyId,
},
@@ -89,13 +117,17 @@ export async function assignDuty(userId: string, dutyId: string) {
}
export async function removeDutyAssignment(assignmentId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try {
await prisma.parentDutyAssignment.delete({
where: {
id: assignmentId,
kitaId: session.user.kitaId!,
kitaId,
},
});
+22 -6
View File
@@ -2,7 +2,6 @@
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";
@@ -15,13 +14,30 @@ import {
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[];
type DutyWithAssignments = {
id: string;
name: string;
assignments: {
id: string;
}[];
};
type DutyUser = {
id: string;
firstName: string;
lastName: string;
};
type UserDutyAssignment = {
id: string;
dutyId: string;
duty: {
name: string;
};
};
export function DutyManager({
@@ -29,9 +45,9 @@ export function DutyManager({
allDuties,
userAssignments,
}: {
user: User;
user: DutyUser;
allDuties: DutyWithAssignments[];
userAssignments: (ParentDutyAssignment & { duty: ParentDuty })[];
userAssignments: UserDutyAssignment[];
}) {
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
@@ -0,0 +1,350 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { Pencil, Plus, 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 { updateFamilyAction } from "./actions";
type EditableParent = {
id: string;
firstName: string;
lastName: string;
email: string;
};
type EditableChild = {
id: string;
firstName: string;
lastName: string;
};
type DraftParent = EditableParent & {
kind: "existing" | "new";
};
type DraftChild = EditableChild & {
kind: "existing" | "new";
};
type EditableFamily = {
id: string;
name: string;
parents: EditableParent[];
children: EditableChild[];
};
export function EditFamilyDialog({ family }: { family: EditableFamily }) {
const [isPending, startTransition] = useTransition();
const initialParents = useMemo(
() =>
family.parents.map((parent) => ({
...parent,
kind: "existing" as const,
})),
[family.parents],
);
const initialChildren = useMemo(
() =>
family.children.map((child) => ({
...child,
kind: "existing" as const,
})),
[family.children],
);
const [parents, setParents] = useState<DraftParent[]>(initialParents);
const [children, setChildren] = useState<DraftChild[]>(initialChildren);
const [removedChildIds, setRemovedChildIds] = useState<string[]>([]);
function resetState() {
setParents(initialParents);
setChildren(initialChildren);
setRemovedChildIds([]);
}
function addParent() {
if (parents.length >= 2) return;
setParents((current) => [
...current,
{
id: `new-parent-${crypto.randomUUID()}`,
firstName: "",
lastName: "",
email: "",
kind: "new",
},
]);
}
function removeNewParent(parent: DraftParent) {
if (parent.kind !== "new") return;
setParents((current) => current.filter((item) => item.id !== parent.id));
}
function addChild() {
setChildren((current) => [
...current,
{
id: `new-child-${crypto.randomUUID()}`,
firstName: "",
lastName: "",
kind: "new",
},
]);
}
function removeChild(child: DraftChild) {
setChildren((current) => current.filter((item) => item.id !== child.id));
if (child.kind === "existing") {
setRemovedChildIds((current) => [...current, child.id]);
}
}
function handleSubmit(formData: FormData) {
const payload = {
familyId: family.id,
familyName: String(formData.get("familyName") ?? ""),
parents: parents
.filter((parent) => parent.kind === "existing")
.map((parent) => ({
id: parent.id,
firstName: String(formData.get(`parentFirstName_${parent.id}`) ?? ""),
lastName: String(formData.get(`parentLastName_${parent.id}`) ?? ""),
email: String(formData.get(`parentEmail_${parent.id}`) ?? ""),
})),
newParents: parents
.filter((parent) => parent.kind === "new")
.map((parent) => ({
firstName: String(formData.get(`parentFirstName_${parent.id}`) ?? ""),
lastName: String(formData.get(`parentLastName_${parent.id}`) ?? ""),
email: String(formData.get(`parentEmail_${parent.id}`) ?? ""),
})),
children: children
.filter((child) => child.kind === "existing")
.map((child) => ({
id: child.id,
firstName: String(formData.get(`childFirstName_${child.id}`) ?? ""),
lastName: String(formData.get(`childLastName_${child.id}`) ?? ""),
})),
newChildren: children
.filter((child) => child.kind === "new")
.map((child) => ({
firstName: String(formData.get(`childFirstName_${child.id}`) ?? ""),
lastName: String(formData.get(`childLastName_${child.id}`) ?? ""),
})),
removedChildIds,
};
startTransition(async () => {
const result = await updateFamilyAction(payload);
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Familie aktualisiert.");
setRemovedChildIds([]);
});
}
return (
<Dialog onOpenChange={(open) => !open && resetState()}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1">
<Pencil className="h-4 w-4" />
<span className="hidden sm:inline">Details</span>
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<form action={handleSubmit}>
<DialogHeader>
<DialogTitle>Familie bearbeiten</DialogTitle>
<DialogDescription>
Aktualisiere Haushalt, Elternteile und Kinder.
</DialogDescription>
</DialogHeader>
<div className="grid gap-6 py-4">
<div className="grid gap-2">
<Label htmlFor={`familyName-${family.id}`}>Familienname</Label>
<Input
id={`familyName-${family.id}`}
name="familyName"
defaultValue={family.name}
required
/>
</div>
<fieldset className="grid gap-3">
<div className="flex items-center justify-between gap-3">
<legend className="text-sm font-semibold">Elternteile</legend>
{parents.length < 2 && (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1"
onClick={addParent}
disabled={isPending}
>
<Plus className="h-3.5 w-3.5" />
Elternteil hinzufügen
</Button>
)}
</div>
{parents.map((parent, index) => (
<div key={parent.id} className="rounded-md border p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-xs font-medium text-muted-foreground">
Elternteil {index + 1}
</p>
{parent.kind === "new" && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-destructive hover:text-destructive"
onClick={() => removeNewParent(parent)}
disabled={isPending}
>
<Trash2 className="h-3.5 w-3.5" />
Entfernen
</Button>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Field
id={`parentFirstName-${parent.id}`}
name={`parentFirstName_${parent.id}`}
label="Vorname"
defaultValue={parent.firstName}
/>
<Field
id={`parentLastName-${parent.id}`}
name={`parentLastName_${parent.id}`}
label="Nachname"
defaultValue={parent.lastName}
/>
</div>
<div className="mt-3">
<Field
id={`parentEmail-${parent.id}`}
name={`parentEmail_${parent.id}`}
label="E-Mail-Adresse"
type="email"
defaultValue={parent.email}
/>
</div>
</div>
))}
</fieldset>
<fieldset className="grid gap-3">
<div className="flex items-center justify-between gap-3">
<legend className="text-sm font-semibold">Kinder</legend>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1"
onClick={addChild}
disabled={isPending}
>
<Plus className="h-3.5 w-3.5" />
Kind hinzufügen
</Button>
</div>
{children.length === 0 ? (
<p className="text-sm text-muted-foreground">
Keine Kinder verknüpft.
</p>
) : (
children.map((child, index) => (
<div key={child.id} className="rounded-md border p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-xs font-medium text-muted-foreground">
Kind {index + 1}
</p>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-destructive hover:text-destructive"
onClick={() => removeChild(child)}
disabled={isPending}
>
<Trash2 className="h-3.5 w-3.5" />
Entfernen
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Field
id={`childFirstName-${child.id}`}
name={`childFirstName_${child.id}`}
label="Vorname"
defaultValue={child.firstName}
/>
<Field
id={`childLastName-${child.id}`}
name={`childLastName_${child.id}`}
label="Nachname"
defaultValue={child.lastName}
/>
</div>
</div>
))
)}
</fieldset>
</div>
<DialogFooter>
<Button type="submit" disabled={isPending}>
{isPending ? "Speichert..." : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function Field({
id,
name,
label,
type = "text",
defaultValue,
}: {
id: string;
name: string;
label: string;
type?: string;
defaultValue: string;
}) {
return (
<div className="grid gap-2">
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
name={name}
type={type}
defaultValue={defaultValue}
required
/>
</div>
);
}
+100 -40
View File
@@ -13,54 +13,79 @@ import {
} from "@/components/ui/table";
import { AddFamilyDialog } from "./add-family-dialog";
import { DutyManager } from "./duty-manager";
import { EditFamilyDialog } from "./edit-family-dialog";
export const metadata = { title: "Familienverwaltung · Kita-Planer" };
// =====================================================================
// /dashboard/families · Server Component
// ---------------------------------------------------------------------
// Zeigt alle Elternteile (ELTERN) und deren Kinder für die aktuelle
// Zeigt alle Haushalte und deren Eltern/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]);
// Guard: Nur Admins dürfen die vollständige Familienliste sehen.
const session = await requireRole([UserRole.ADMIN]);
// Alle ELTERN-User der Kita mit ihren verknüpften Kindern laden
const kitaId = session.user.kitaId!;
// Alle Haushalte der Kita mit Elternteilen und 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,
},
const families = await prisma.family.findMany({
where: { kitaId },
select: {
id: true,
firstName: true,
lastName: true,
email: true,
passwordHash: true, // "" → Invite ausstehend; sonst aktiv
emailVerifiedAt: true,
childLinks: {
name: true,
users: {
select: {
child: {
select: { id: true, firstName: true, lastName: true },
id: true,
firstName: true,
lastName: true,
email: true,
emailVerifiedAt: true,
dutyAssignments: {
select: {
id: true,
dutyId: true,
duty: {
select: {
name: true,
},
},
},
},
},
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
},
dutyAssignments: {
include: { duty: true },
children: {
select: { id: true, firstName: true, lastName: true },
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
},
},
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
orderBy: { name: "asc" },
});
const allDuties = await prisma.parentDuty.findMany({
where: { kitaId: session.user.kitaId! },
include: { assignments: true },
where: { kitaId },
select: {
id: true,
name: true,
assignments: {
select: {
id: true,
},
},
},
orderBy: { name: "asc" },
});
const childCount = families.reduce(
(acc, family) => acc + family.children.length,
0,
);
const canManageDuties = session.user.role === UserRole.ADMIN;
return (
<div className="px-8 py-8">
{/* Header */}
@@ -72,7 +97,7 @@ export default async function FamiliesPage() {
<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`}
: `${families.length} ${families.length === 1 ? "Familie" : "Familien"} · ${childCount} Kinder`}
</p>
</div>
<AddFamilyDialog />
@@ -85,8 +110,8 @@ export default async function FamiliesPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>Elternteil</TableHead>
<TableHead>E-Mail</TableHead>
<TableHead>Familie</TableHead>
<TableHead>Elternteile</TableHead>
<TableHead>Kinder</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
@@ -94,23 +119,35 @@ export default async function FamiliesPage() {
</TableHeader>
<TableBody>
{families.map((family) => {
const isActive = !!family.emailVerifiedAt;
const children = family.childLinks.map((l) => l.child);
const hasPendingInvite = family.users.some(
(user) => !user.emailVerifiedAt,
);
return (
<TableRow key={family.id}>
<TableCell className="font-medium">
{family.firstName} {family.lastName}
</TableCell>
<TableCell className="text-muted-foreground">
{family.email}
{family.name}
</TableCell>
<TableCell>
{children.length === 0 ? (
<div className="space-y-1.5">
{family.users.map((parent) => (
<div key={parent.id}>
<div className="font-medium">
{parent.firstName} {parent.lastName}
</div>
<div className="text-xs text-muted-foreground">
{parent.email}
</div>
</div>
))}
</div>
</TableCell>
<TableCell>
{family.children.length === 0 ? (
<span className="text-sm text-muted-foreground"></span>
) : (
<div className="flex flex-wrap gap-1">
{children.map((child) => (
{family.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"
@@ -122,16 +159,39 @@ export default async function FamiliesPage() {
)}
</TableCell>
<TableCell>
<Badge variant={isActive ? "success" : "warning"}>
{isActive ? "Aktiv" : "Eingeladen"}
<Badge variant={hasPendingInvite ? "warning" : "success"}>
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
</Badge>
</TableCell>
<TableCell className="text-right">
<DutyManager
user={family as any}
allDuties={allDuties as any}
userAssignments={family.dutyAssignments}
/>
<div className="flex flex-wrap justify-end gap-1">
<EditFamilyDialog
family={{
id: family.id,
name: family.name,
parents: family.users.map((parent) => ({
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
})),
children: family.children,
}}
/>
{canManageDuties &&
family.users.map((parent) => (
<DutyManager
key={parent.id}
user={{
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
}}
allDuties={allDuties}
userAssignments={parent.dutyAssignments}
/>
))}
</div>
</TableCell>
</TableRow>
);
@@ -1,7 +1,6 @@
"use client";
import { useState, useTransition } from "react";
import { MitbringselItem } from "@prisma/client";
import { Trash2, Plus, Utensils } from "lucide-react";
import { toast } from "sonner";
@@ -9,7 +8,10 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { addMitbringsel, deleteMitbringsel } from "../actions";
type ItemWithUser = MitbringselItem & {
type MitbringselItemDto = {
id: string;
userId: string;
content: string;
user: { firstName: string; lastName: string };
};
@@ -20,7 +22,7 @@ export function MitbringselList({
isAdmin,
}: {
terminId: string;
items: ItemWithUser[];
items: MitbringselItemDto[];
currentUserId: string;
isAdmin: boolean;
}) {
@@ -5,17 +5,20 @@ 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 & {
export type PendingTerminDto = {
id: string;
title: string;
startDate: Date;
allDay: boolean;
createdBy: { firstName: string; lastName: string } | null;
};
export function PendingAnfragen({ termine }: { termine: PendingTermin[] }) {
export function PendingAnfragen({ termine }: { termine: PendingTerminDto[] }) {
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">
@@ -37,7 +40,7 @@ export function PendingAnfragen({ termine }: { termine: PendingTermin[] }) {
);
}
function PendingTerminCard({ termin }: { termin: PendingTermin }) {
function PendingTerminCard({ termin }: { termin: PendingTerminDto }) {
const [isPending, startTransition] = useTransition();
const handleApprove = () => {
@@ -1,11 +1,11 @@
"use client";
import { Termin, MitbringselItem, TerminType, TerminStatus } from "@prisma/client";
import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Calendar, Clock, MapPin, AlignLeft } from "lucide-react";
import { AlignLeft, Calendar, Clock, WashingMachine } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { MitbringselList } from "./mitbringsel-list";
import { toggleMitbringselList } from "../actions";
@@ -14,18 +14,43 @@ import { Label } from "@/components/ui/label";
import { useTransition } from "react";
import { toast } from "sonner";
type TerminWithItems = Termin & {
mitbringselItems?: (MitbringselItem & {
export type TerminListItemDto = {
kind: "termin";
id: string;
title: string;
description: string | null;
type: TerminType;
status: TerminStatus;
startDate: Date;
endDate: Date;
allDay: boolean;
mitbringselListEnabled: boolean;
mitbringselItems?: {
id: string;
userId: string;
content: string;
user: { firstName: string; lastName: string };
})[];
}[];
};
export type DutyCalendarItemDto = {
kind: "duty";
id: string;
title: string;
familyName: string;
status: DutyAssignmentStatus;
startDate: Date;
endDate: Date;
};
export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto;
export function TerminList({
termine,
userId,
isAdmin,
}: {
termine: TerminWithItems[];
termine: CalendarListItemDto[];
userId: string;
isAdmin: boolean;
}) {
@@ -46,23 +71,58 @@ export function TerminList({
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}
/>
termin.kind === "duty" ? (
<DutyCard key={`duty-${termin.id}`} duty={termin} />
) : (
<TerminCard
key={`termin-${termin.id}`}
termin={termin}
userId={userId}
isAdmin={isAdmin}
/>
)
))}
</div>
);
}
function DutyCard({ duty }: { duty: DutyCalendarItemDto }) {
return (
<Card className="flex flex-col overflow-hidden border-emerald-200 bg-emerald-50/60">
<CardHeader className="pb-3">
<div className="mb-2 flex items-start justify-between gap-2">
<Badge className="border-transparent bg-emerald-600 text-white hover:bg-emerald-700">
Elterndienst
</Badge>
</div>
<CardTitle className="line-clamp-2 leading-tight">
{duty.title}
</CardTitle>
<CardDescription className="mt-1 flex items-center gap-1 text-sm">
<Clock className="h-3.5 w-3.5" />
{format(duty.startDate, "dd.MM.", { locale: de })} -{" "}
{format(duty.endDate, "dd.MM.yyyy", { locale: de })}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 pb-4">
<div className="flex items-start gap-2 text-sm text-emerald-900">
<WashingMachine className="mt-0.5 h-4 w-4 shrink-0" />
<p>
Eingeteilt: <span className="font-medium">{duty.familyName}</span>
</p>
</div>
</CardContent>
</Card>
);
}
function TerminCard({
termin,
userId,
isAdmin,
}: {
termin: TerminWithItems;
termin: TerminListItemDto;
userId: string;
isAdmin: boolean;
}) {
+20 -4
View File
@@ -55,6 +55,10 @@ export async function createTerminRequest(rawPayload: unknown) {
export async function createTerminAdmin(rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = terminSchema.safeParse(rawPayload);
if (!parsed.success) {
@@ -66,7 +70,7 @@ export async function createTerminAdmin(rawPayload: unknown) {
try {
await prisma.termin.create({
data: {
kitaId: session.user.kitaId,
kitaId,
createdById: session.user.id,
title: data.title,
description: data.description,
@@ -90,12 +94,16 @@ export async function createTerminAdmin(rawPayload: unknown) {
export async function approveTermin(terminId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try {
await prisma.termin.update({
where: {
id: terminId,
kitaId: session.user.kitaId,
kitaId,
},
data: {
status: TerminStatus.CONFIRMED,
@@ -114,12 +122,16 @@ export async function approveTermin(terminId: string) {
export async function rejectTermin(terminId: string, reason?: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try {
await prisma.termin.update({
where: {
id: terminId,
kitaId: session.user.kitaId,
kitaId,
},
data: {
status: TerminStatus.REJECTED,
@@ -139,12 +151,16 @@ export async function rejectTermin(terminId: string, reason?: string) {
export async function toggleMitbringselList(terminId: string, enabled: boolean) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try {
await prisma.termin.update({
where: {
id: terminId,
kitaId: session.user.kitaId,
kitaId,
},
data: {
mitbringselListEnabled: enabled,
+110 -22
View File
@@ -1,11 +1,11 @@
import { DutyAssignmentStatus, TerminStatus, UserRole } from "@prisma/client";
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";
import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-anfragen";
import { TerminList, type CalendarListItemDto } from "./_components/termin-list";
import { TerminRequestModal } from "./_components/termin-request-modal";
export default async function KalenderPage({
searchParams,
@@ -14,51 +14,139 @@ export default async function KalenderPage({
}) {
const session = await requireKitaSession();
const isAdmin =
session.user.role === UserRole.ADMIN || session.user.role === UserRole.KOORDINATOR;
session.user.role === UserRole.ADMIN ||
session.user.role === UserRole.KOORDINATOR;
const currentTab = searchParams.tab || "übersicht";
// Fetch confirmed events
const confirmedTermine = await prisma.termin.findMany({
const confirmedTermineRows = await prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.CONFIRMED,
},
include: {
select: {
id: true,
title: true,
description: true,
type: true,
status: true,
startDate: true,
endDate: true,
allDay: true,
mitbringselListEnabled: true,
mitbringselItems: {
include: {
user: { select: { firstName: true, lastName: true } },
select: {
id: true,
userId: true,
content: true,
user: {
select: {
firstName: true,
lastName: true,
},
},
},
},
},
orderBy: { startDate: "asc" },
});
// Fetch user's own pending events
const myPendingTermine = await prisma.termin.findMany({
const myPendingTermineRows = await prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.PENDING,
createdById: session.user.id,
},
select: {
id: true,
title: true,
description: true,
type: true,
status: true,
startDate: true,
endDate: true,
allDay: true,
mitbringselListEnabled: true,
mitbringselItems: {
select: {
id: true,
userId: true,
content: true,
user: {
select: {
firstName: true,
lastName: true,
},
},
},
},
},
orderBy: { startDate: "asc" },
});
// Combine for general view
const allUserTermine = [...confirmedTermine, ...myPendingTermine].sort(
(a, b) => a.startDate.getTime() - b.startDate.getTime()
);
const dutyAssignmentRows = await prisma.dutyAssignment.findMany({
where: {
kitaId: session.user.kitaId,
status: DutyAssignmentStatus.PLANNED,
endDate: { gte: new Date() },
},
select: {
id: true,
startDate: true,
endDate: true,
status: true,
dutyType: {
select: {
name: true,
},
},
family: {
select: {
name: true,
},
},
},
orderBy: { startDate: "asc" },
});
// If admin, fetch all pending events
let allPendingTermine: any[] = [];
const allUserTermine: CalendarListItemDto[] = [
...confirmedTermineRows.map((termin) => ({
...termin,
kind: "termin" as const,
})),
...myPendingTermineRows.map((termin) => ({
...termin,
kind: "termin" as const,
})),
...dutyAssignmentRows.map((assignment) => ({
kind: "duty" as const,
id: assignment.id,
title: assignment.dutyType.name,
familyName: assignment.family.name,
status: assignment.status,
startDate: assignment.startDate,
endDate: assignment.endDate,
})),
].sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
let allPendingTermine: PendingTerminDto[] = [];
if (isAdmin) {
allPendingTermine = await prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.PENDING,
},
include: {
createdBy: { select: { firstName: true, lastName: true } },
select: {
id: true,
title: true,
startDate: true,
allDay: true,
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: { createdAt: "desc" },
});
@@ -94,7 +182,7 @@ export default async function KalenderPage({
</a>
<a
href="/dashboard/kalender?tab=anfragen"
className={`pb-2 text-sm font-medium transition-colors hover:text-primary flex items-center gap-2 ${
className={`flex items-center gap-2 pb-2 text-sm font-medium transition-colors hover:text-primary ${
currentTab === "anfragen"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground"
+10 -3
View File
@@ -1,13 +1,20 @@
import Link from "next/link";
import type { Metadata } from "next";
import { Baby, LogOut } from "lucide-react";
import { auth, signOut } from "@/auth";
import { 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";
export const metadata: Metadata = {
robots: {
index: false,
follow: false,
},
};
// =====================================================================
// Dashboard-Layout · Linke Sidebar
// ---------------------------------------------------------------------
@@ -47,7 +54,7 @@ export default async function DashboardLayout({
{/* Navigation */}
<div className="flex-1 overflow-y-auto py-4">
<SidebarNav role={session.user.role as any} />
<SidebarNav role={session.user.role} />
</div>
{/* Footer: User-Info + Abmelden */}
+155
View File
@@ -0,0 +1,155 @@
"use client";
import { useState, useTransition } from "react";
import { Bell, FileText, Megaphone } from "lucide-react";
import { markAnnouncementRead } from "@/actions/announcements";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { MarkdownContent } from "@/components/markdown-content";
export type NewsTickerItemDto = {
id: string;
title: string;
content: string;
createdAt: string;
authorName: string;
isUnread: boolean;
attachments: {
id: string;
fileName: string;
fileUrl: string;
fileType: string;
}[];
};
export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
const [selected, setSelected] = useState<NewsTickerItemDto | null>(null);
const [readIds, setReadIds] = useState<Set<string>>(new Set());
const [, startTransition] = useTransition();
if (items.length === 0) {
return null;
}
function openAnnouncement(item: NewsTickerItemDto) {
setSelected(item);
if (!item.isUnread || readIds.has(item.id)) return;
setReadIds((current) => new Set(current).add(item.id));
startTransition(async () => {
await markAnnouncementRead(item.id);
});
}
return (
<>
<Card className="mb-8 border-emerald-200 bg-emerald-50/60">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2 text-lg">
<Megaphone className="h-5 w-5 text-emerald-700" />
Schwarzes Brett
</CardTitle>
<CardDescription>
Die neuesten offiziellen Ankündigungen deiner Kita.
</CardDescription>
</div>
<Badge variant="secondary">{items.length}</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-3 lg:grid-cols-3">
{items.map((item) => {
const isUnread = item.isUnread && !readIds.has(item.id);
return (
<button
key={item.id}
type="button"
onClick={() => openAnnouncement(item)}
className="rounded-md border bg-background p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-emerald-300 hover:shadow-md"
>
<div className="mb-3 flex items-start justify-between gap-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Bell className="h-3.5 w-3.5" />
{item.createdAt}
</div>
{isUnread && (
<Badge className="bg-emerald-600 text-white">NEU</Badge>
)}
</div>
<h3 className="line-clamp-2 font-semibold leading-snug">
{item.title}
</h3>
<p className="mt-2 line-clamp-3 text-sm leading-6 text-muted-foreground">
{item.content}
</p>
{item.attachments.length > 0 && (
<p className="mt-3 flex items-center gap-1 text-xs font-medium text-emerald-700">
<FileText className="h-3.5 w-3.5" />
{item.attachments.length} Anhang
{item.attachments.length === 1 ? "" : "e"}
</p>
)}
</button>
);
})}
</CardContent>
</Card>
<Dialog open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
{selected && (
<>
<DialogHeader>
<DialogTitle className="text-2xl">{selected.title}</DialogTitle>
<DialogDescription>
{selected.createdAt} · {selected.authorName}
</DialogDescription>
</DialogHeader>
<div className="rounded-md border bg-muted/30 p-4 text-sm">
<MarkdownContent content={selected.content} />
</div>
{selected.attachments.length > 0 && (
<div>
<p className="mb-2 text-sm font-medium">Anhänge</p>
<div className="grid gap-2">
{selected.attachments.map((attachment) => (
<Button
key={attachment.id}
asChild
variant="outline"
className="justify-start"
>
<a href={attachment.fileUrl} target="_blank" rel="noreferrer">
<FileText className="h-4 w-4" />
{attachment.fileName}
</a>
</Button>
))}
</div>
</div>
)}
</>
)}
</DialogContent>
</Dialog>
</>
);
}
+6 -4
View File
@@ -47,6 +47,9 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
if (!session.user.familyId) {
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
}
// ── 2. Validierung (Soll-Tage) ─────────────────────────────────────
const kita = await prisma.kita.findUniqueOrThrow({
@@ -61,12 +64,12 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
};
}
// Sicherstellen, dass alle Kinder zum angemeldeten User gehören und zur kitaId passen
// Sicherstellen, dass alle Kinder zum Haushalt des angemeldeten Users gehören.
const validChildren = await prisma.child.findMany({
where: {
id: { in: childrenIds },
kitaId,
parentLinks: { some: { userId: session.user.id } },
familyId: session.user.familyId,
},
select: { id: true },
});
@@ -101,11 +104,10 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
// ── 3. Datenbank-Transaktion ───────────────────────────────────────
try {
await prisma.$transaction(async (tx) => {
// a) Alle *eigenen* bestehenden Einträge im Zielmonat für diese Kinder löschen
// a) Alle bestehenden Haushaltseinträ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),
@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import { ArrowRight, CalendarHeart } from "lucide-react";
import { Button } from "@/components/ui/button";
import { NotdienstForm } from "./notdienst-form";
type NotdienstEntryProps = {
hasAnyAvailability: boolean;
targetYear: number;
targetMonth: number;
isLocked: boolean;
requiredDaysTotal: number;
initialSelectedDates: string[];
childrenIds: string[];
};
export function NotdienstEntry({
hasAnyAvailability,
targetYear,
targetMonth,
isLocked,
requiredDaysTotal,
initialSelectedDates,
childrenIds,
}: NotdienstEntryProps) {
const [showForm, setShowForm] = useState(hasAnyAvailability);
if (!showForm) {
return (
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-gradient-to-b from-emerald-50/70 to-background p-8 text-center">
<div className="mx-auto max-w-lg">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 shadow-sm">
<CalendarHeart className="h-10 w-10" />
</div>
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
Willkommen beim Notdienst!
</h2>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Es sieht so aus, als hättest du noch keine Termine eingetragen.
</p>
<Button
type="button"
className="mt-6"
onClick={() => setShowForm(true)}
>
Jetzt erste Pflicht-Termine eintragen
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}
return (
<NotdienstForm
targetYear={targetYear}
targetMonth={targetMonth}
isLocked={isLocked}
requiredDaysTotal={requiredDaysTotal}
initialSelectedDates={initialSelectedDates}
childrenIds={childrenIds}
/>
);
}
+72 -29
View File
@@ -1,10 +1,12 @@
import Link from "next/link";
import { UserRole } from "@prisma/client";
import { Info, Calendar } from "lucide-react";
import { Info, Calendar, ArrowRight, ShieldAlert } 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";
import { Button } from "@/components/ui/button";
import { NotdienstEntry } from "./notdienst-entry";
export const metadata = { title: "Notdienst · Kita-Planer" };
@@ -29,34 +31,51 @@ export default async function NotdienstPage() {
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 },
});
// Nur aktive Kinder des eigenen Haushalts laden.
const children = session.user.familyId
? await prisma.child.findMany({
where: {
kitaId,
familyId: session.user.familyId,
active: true,
},
select: { id: true, firstName: true },
})
: [];
const targetData = getTargetMonthData();
const { targetYear, targetMonth, monthName, isLocked } = targetData;
const requiredDaysTotal = children.length * kita.notdienstMinPerChildPerMonth;
const childIds = children.map((child) => child.id);
// 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 },
});
// Bisherige Einträge für den Zielmonat laden, haushaltsweit über die Kinder.
const [availabilities, availabilityCountForCurrentOrNextMonth] =
children.length > 0
? await Promise.all([
prisma.notdienstAvailability.findMany({
where: {
kitaId,
childId: { in: childIds },
date: {
gte: new Date(targetYear, targetMonth - 1, 1),
lt: new Date(targetYear, targetMonth, 1),
},
},
select: { date: true },
}),
prisma.notdienstAvailability.count({
where: {
kitaId,
childId: { in: childIds },
date: {
gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
lt: new Date(new Date().getFullYear(), new Date().getMonth() + 2, 1),
},
},
}),
])
: [[], 0];
const selectedDates = availabilities.map((a) => a.date.toISOString());
@@ -86,19 +105,43 @@ export default async function NotdienstPage() {
</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>
<NotdienstLockout />
) : (
<NotdienstForm
<NotdienstEntry
hasAnyAvailability={availabilityCountForCurrentOrNextMonth > 0}
targetYear={targetYear}
targetMonth={targetMonth}
isLocked={isLocked}
requiredDaysTotal={requiredDaysTotal}
initialSelectedDates={selectedDates}
childrenIds={children.map((c) => c.id)}
childrenIds={childIds}
/>
)}
</div>
);
}
function NotdienstLockout() {
return (
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-muted/20 p-8 text-center">
<div className="mx-auto max-w-lg">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-amber-100 text-amber-700 shadow-sm">
<ShieldAlert className="h-10 w-10" />
</div>
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
Erst Kinder hinterlegen
</h2>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Bitte lege zuerst deine Kinder im Profil an, bevor du Notdienste
übernimmst.
</p>
<Button asChild className="mt-6">
<Link href="/dashboard/profil">
Zum Profil
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</div>
</div>
);
}
+22 -7
View File
@@ -37,7 +37,7 @@ export async function generatePlanAction() {
lt: new Date(targetYear, targetMonth, 1),
},
},
include: { child: true, user: true },
include: { child: { select: { familyId: true } } },
});
// 2. Werktage holen
@@ -63,8 +63,8 @@ export async function generatePlanAction() {
// 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;
const countA = familyUsageCount[a.child.familyId] || 0;
const countB = familyUsageCount[b.child.familyId] || 0;
if (countA === countB) {
return Math.random() - 0.5; // Zufallsmischung
}
@@ -79,8 +79,8 @@ export async function generatePlanAction() {
});
// Usage-Counter erhöhen
familyUsageCount[selected.userId] =
(familyUsageCount[selected.userId] || 0) + 1;
familyUsageCount[selected.child.familyId] =
(familyUsageCount[selected.child.familyId] || 0) + 1;
}
// 4. In der DB speichern
@@ -161,6 +161,19 @@ export async function updateAssignmentAction(
// Neues anlegen, falls ausgewählt
if (newChildId) {
const child = await tx.child.findFirst({
where: {
id: newChildId,
kitaId,
active: true,
},
select: { id: true },
});
if (!child) {
throw new Error("Kind gehört nicht zu dieser Kita.");
}
await tx.notdienstAssignment.create({
data: {
kitaId,
@@ -174,7 +187,9 @@ export async function updateAssignmentAction(
revalidatePath("/dashboard/notdienst/plan");
return { success: true };
} catch (error: any) {
return { error: error.message || "Update fehlgeschlagen." };
} catch (error) {
return {
error: error instanceof Error ? error.message : "Update fehlgeschlagen.",
};
}
}
+27 -7
View File
@@ -19,9 +19,15 @@ export default async function PlanungsZentralePage() {
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
},
include: {
select: {
id: true,
status: true,
assignments: {
include: { child: { include: { parentLinks: { include: { user: true } } } } },
select: {
id: true,
childId: true,
date: true,
},
},
},
});
@@ -34,16 +40,30 @@ export default async function PlanungsZentralePage() {
// aber für MVP reichen alle aktiven Kinder.
const allChildrenRaw = await prisma.child.findMany({
where: { kitaId, active: true },
include: {
parentLinks: { include: { user: true } },
select: {
id: true,
firstName: true,
lastName: true,
family: {
select: {
name: true,
users: {
select: {
firstName: true,
lastName: 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";
const parentName =
c.family.users
.map((parent) => `${parent.firstName} ${parent.lastName}`)
.join(", ") || c.family.name;
return {
id: c.id,
name: `${c.firstName} ${c.lastName}`,
@@ -1,6 +1,6 @@
"use client";
import { useState, useTransition } from "react";
import { useTransition } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { NotdienstPlanStatus } from "@prisma/client";
+247 -16
View File
@@ -1,6 +1,8 @@
import Link from "next/link";
import { Users, CalendarCheck2, ShieldCheck, AlertTriangle } from "lucide-react";
import { UserRole, NotdienstPlanStatus } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Users, CalendarCheck2, ShieldCheck, AlertTriangle, ClipboardList } from "lucide-react";
import { DutyAssignmentStatus, UserRole, NotdienstPlanStatus } from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
@@ -13,7 +15,10 @@ import {
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { OnboardingDialog } from "@/components/OnboardingDialog";
import { AlertButton } from "./alert-button";
import { AbsenceCard } from "./absence-card";
import { NewsTicker } from "./news-ticker";
export const metadata = { title: "Übersicht · Kita-Planer" };
@@ -30,22 +35,166 @@ export default async function DashboardPage() {
},
});
// Schnelle Statistiken für die Übersicht
const [familyCount, childCount] = await Promise.all([
prisma.user.count({
where: { kitaId: session.user.kitaId, role: UserRole.ELTERN },
const today = new Date();
today.setHours(0, 0, 0, 0);
// Schnelle Statistiken und persönliche Dienste für die Übersicht.
const [
familyCount,
childCount,
upcomingDuties,
onboardingUser,
absenceChildren,
upcomingAbsences,
latestAnnouncements,
] = await Promise.all([
prisma.family.count({
where: { kitaId: session.user.kitaId },
}),
prisma.child.count({
where: { kitaId: session.user.kitaId },
}),
session.user.familyId
? prisma.dutyAssignment.findMany({
where: {
kitaId: session.user.kitaId,
familyId: session.user.familyId,
status: DutyAssignmentStatus.PLANNED,
endDate: { gte: today },
},
select: {
id: true,
startDate: true,
endDate: true,
dutyType: {
select: {
name: true,
},
},
},
orderBy: { startDate: "asc" },
take: 5,
})
: Promise.resolve([]),
prisma.user.findFirstOrThrow({
where: {
id: session.user.id,
kitaId: session.user.kitaId,
},
select: {
phone: true,
street: true,
postalCode: true,
city: true,
familyId: true,
role: true,
family: {
select: {
_count: {
select: {
children: true,
},
},
},
},
},
}),
session.user.familyId
? prisma.child.findMany({
where: {
kitaId: session.user.kitaId,
familyId: session.user.familyId,
active: true,
},
select: {
id: true,
firstName: true,
lastName: true,
},
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
})
: Promise.resolve([]),
session.user.familyId
? prisma.absence.findMany({
where: {
kitaId: session.user.kitaId,
endDate: { gte: today },
child: {
familyId: session.user.familyId,
},
},
select: {
id: true,
reason: true,
note: true,
startDate: true,
endDate: true,
child: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ startDate: "asc" }],
take: 6,
})
: Promise.resolve([]),
prisma.announcement.findMany({
where: { kitaId: session.user.kitaId },
select: {
id: true,
title: true,
content: true,
createdAt: true,
author: {
select: {
firstName: true,
lastName: true,
},
},
attachments: {
select: {
id: true,
fileName: true,
fileUrl: true,
fileType: true,
},
},
reads: {
where: { userId: session.user.id },
select: { id: true },
take: 1,
},
},
orderBy: { createdAt: "desc" },
take: 3,
}),
]);
const isAdmin =
session.user.role === UserRole.ADMIN ||
session.user.role === UserRole.SUPERADMIN;
const canReportAbsences = session.user.role !== UserRole.ERZIEHER;
return (
<div className="px-8 py-8">
<OnboardingDialog
user={{
phone: onboardingUser.phone,
street: onboardingUser.street,
postalCode: onboardingUser.postalCode,
city: onboardingUser.city,
familyId: onboardingUser.familyId,
role: onboardingUser.role,
}}
family={
onboardingUser.family
? { childrenCount: onboardingUser.family._count.children }
: null
}
/>
<div className="mb-8">
<h1 className="text-2xl font-semibold tracking-tight">
Willkommen, {session.user.name?.split(" ")[0] ?? "zusammen"}!
@@ -55,6 +204,25 @@ export default async function DashboardPage() {
</p>
</div>
<NewsTicker
items={latestAnnouncements.map((announcement) => ({
id: announcement.id,
title: announcement.title,
content: announcement.content,
createdAt: format(announcement.createdAt, "dd.MM.yyyy", {
locale: de,
}),
authorName: `${announcement.author.firstName} ${announcement.author.lastName}`,
isUnread: announcement.reads.length === 0,
attachments: announcement.attachments.map((attachment) => ({
id: attachment.id,
fileName: attachment.fileName,
fileUrl: attachment.fileUrl,
fileType: attachment.fileType,
})),
}))}
/>
{/* Heutiger Notdienst (Nur für Admins/Koordinatoren) */}
{isAdmin && (
<div className="mb-8">
@@ -62,10 +230,53 @@ export default async function DashboardPage() {
</div>
)}
{canReportAbsences && (
<AbsenceCard
today={format(today, "yyyy-MM-dd")}
childOptions={absenceChildren.map((child) => ({
id: child.id,
name: `${child.firstName} ${child.lastName}`,
}))}
absences={upcomingAbsences.map((absence) => ({
id: absence.id,
childName: `${absence.child.firstName} ${absence.child.lastName}`,
reason: absence.reason,
note: absence.note,
startDate: format(absence.startDate, "yyyy-MM-dd"),
endDate: format(absence.endDate, "yyyy-MM-dd"),
}))}
/>
)}
{upcomingDuties.length > 0 && (
<Card className="mb-8 border-emerald-200 bg-emerald-50/50">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<ClipboardList className="h-5 w-5 text-emerald-700" />
Deine anstehenden Dienste
</CardTitle>
<CardDescription>
Die nächsten Einteilungen für deinen Haushalt.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{upcomingDuties.map((duty) => (
<div key={duty.id} className="rounded-md border bg-background p-3">
<p className="font-medium">{duty.dutyType.name}</p>
<p className="mt-1 text-sm text-muted-foreground">
{format(duty.startDate, "dd.MM.", { locale: de })} bis{" "}
{format(duty.endDate, "dd.MM.yyyy", { locale: de })}
</p>
</div>
))}
</CardContent>
</Card>
)}
{/* Statistik-Kacheln */}
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
label="Elternteile"
label="Familien"
value={familyCount}
description="registrierte Familien"
icon={<Users className="h-5 w-5 text-muted-foreground" />}
@@ -147,15 +358,38 @@ async function TodaysNotdienstCard({ kitaId }: { kitaId: string }) {
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
select: {
id: true,
child: {
select: {
firstName: true,
lastName: true,
family: {
select: {
name: true,
users: {
select: {
firstName: true,
lastName: true,
},
},
},
},
},
},
alerts: {
select: {
status: true,
},
},
},
});
if (!assignment) return null;
const parent = assignment.child.parentLinks[0]?.user;
const parentNames = assignment.child.family.users
.map((parent) => `${parent.firstName} ${parent.lastName}`)
.join(", ");
const existingAlert = assignment.alerts[0]; // Kann PENDING, CONFIRMED oder CANCELLED sein
return (
@@ -172,7 +406,7 @@ async function TodaysNotdienstCard({ kitaId }: { kitaId: string }) {
<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"}
{parentNames || assignment.child.family.name}
</p>
<p className="text-sm text-muted-foreground">
Kind: {assignment.child.firstName} {assignment.child.lastName}
@@ -198,10 +432,7 @@ async function TodaysNotdienstCard({ kitaId }: { kitaId: string }) {
: "Abgebrochen"}
</Badge>
) : (
<AlertButton
assignmentId={assignment.id}
parentUserId={parent?.id || ""}
/>
<AlertButton assignmentId={assignment.id} />
)}
</div>
</CardContent>
@@ -0,0 +1,98 @@
"use client";
import { useTransition } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { updateMyContact } from "../actions";
type ContactFormProps = {
contact: {
phone: string | null;
street: string | null;
postalCode: string | null;
city: string | null;
};
};
export function ContactForm({ contact }: ContactFormProps) {
const [isPending, startTransition] = useTransition();
function handleSubmit(formData: FormData) {
startTransition(async () => {
const result = await updateMyContact({
phone: String(formData.get("phone") ?? ""),
street: String(formData.get("street") ?? ""),
postalCode: String(formData.get("postalCode") ?? ""),
city: String(formData.get("city") ?? ""),
});
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Kontaktdaten gespeichert.");
});
}
return (
<form action={handleSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="phone">Telefonnummer</Label>
<Input
id="phone"
name="phone"
type="tel"
defaultValue={contact.phone ?? ""}
placeholder="+49 30 123456"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="street">Straße und Hausnummer</Label>
<Input
id="street"
name="street"
defaultValue={contact.street ?? ""}
placeholder="Beispielweg 12"
/>
</div>
<div className="grid gap-3 sm:grid-cols-[120px_1fr]">
<div className="grid gap-2">
<Label htmlFor="postalCode">PLZ</Label>
<Input
id="postalCode"
name="postalCode"
defaultValue={contact.postalCode ?? ""}
placeholder="10115"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="city">Ort</Label>
<Input
id="city"
name="city"
defaultValue={contact.city ?? ""}
placeholder="Berlin"
/>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
Speichern
</Button>
</div>
</form>
);
}
@@ -0,0 +1,92 @@
"use client";
import { useState, useTransition } from "react";
import { Home, Loader2 } 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 { createMyFamily } from "../actions";
export function CreateFamilyDialog({
defaultFamilyName,
}: {
defaultFamilyName: string;
}) {
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
function handleSubmit(formData: FormData) {
startTransition(async () => {
const result = await createMyFamily({
familyName: String(formData.get("familyName") ?? ""),
});
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Haushalt angelegt.");
setOpen(false);
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button type="button" size="sm" className="gap-2">
<Home className="h-4 w-4" />
Haushalt anlegen
</Button>
</DialogTrigger>
<DialogContent>
<form action={handleSubmit}>
<DialogHeader>
<DialogTitle>Eigenen Haushalt anlegen</DialogTitle>
<DialogDescription>
Dein Account wird diesem Haushalt zugeordnet. Danach kannst du
deine Kinder im Profil verwalten.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2 py-4">
<Label htmlFor="familyName">Haushaltsname</Label>
<Input
id="familyName"
name="familyName"
defaultValue={defaultFamilyName}
placeholder="Familie Beispiel"
required
/>
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
disabled={isPending}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Haushalt speichern
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -56,8 +56,10 @@ export function DeleteAccountDialog() {
</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.
Wenn du das letzte Elternteil im Haushalt bist, wird die gesamte
Familie inklusive Kinder gelöscht. Gibt es ein weiteres Elternteil,
bleibt der Haushalt für diese Person erhalten und nur dein Account
wird entfernt.
</DialogDescription>
</DialogHeader>
@@ -0,0 +1,243 @@
"use client";
import { useState, useTransition } from "react";
import { Pencil, Plus, 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 { createMyChild, deleteMyChild, updateMyChild } from "../actions";
export type MyChild = {
id: string;
firstName: string;
lastName: string;
dateOfBirth: string | null;
};
export function MyChildrenManager({
items,
canManage,
}: {
items: MyChild[];
canManage: boolean;
}) {
const [createOpen, setCreateOpen] = useState(false);
const [editingChild, setEditingChild] = useState<MyChild | null>(null);
const [isPending, startTransition] = useTransition();
function payloadFromForm(formData: FormData) {
return {
firstName: String(formData.get("firstName") ?? ""),
lastName: String(formData.get("lastName") ?? ""),
dateOfBirth: String(formData.get("dateOfBirth") ?? ""),
};
}
function handleCreate(formData: FormData) {
startTransition(async () => {
const result = await createMyChild(payloadFromForm(formData));
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Kind angelegt.");
setCreateOpen(false);
});
}
function handleUpdate(formData: FormData) {
if (!editingChild) return;
startTransition(async () => {
const result = await updateMyChild(
editingChild.id,
payloadFromForm(formData),
);
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Kind aktualisiert.");
setEditingChild(null);
});
}
function handleDelete(child: MyChild) {
const confirmed = confirm(
`${child.firstName} ${child.lastName} wirklich entfernen?`,
);
if (!confirmed) return;
startTransition(async () => {
const result = await deleteMyChild(child.id);
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Kind entfernt.");
});
}
return (
<div className="space-y-3">
{items.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Kinder verknüpft.</p>
) : (
<div className="flex flex-col gap-2">
{items.map((child) => (
<div
key={child.id}
className="flex items-center justify-between gap-3 rounded-md bg-muted/50 p-2"
>
<div>
<p className="font-medium">
{child.firstName} {child.lastName}
</p>
{child.dateOfBirth && (
<p className="text-xs text-muted-foreground">
Geboren am {new Date(child.dateOfBirth).toLocaleDateString("de-DE")}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setEditingChild(child)}
disabled={isPending || !canManage}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => handleDelete(child)}
disabled={isPending || !canManage}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
{canManage ? (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm" className="gap-2">
<Plus className="h-4 w-4" />
Kind hinzufügen
</Button>
</DialogTrigger>
<DialogContent>
<form action={handleCreate}>
<DialogHeader>
<DialogTitle>Kind hinzufügen</DialogTitle>
<DialogDescription>
Dieses Kind wird mit deinem Haushalt verknüpft.
</DialogDescription>
</DialogHeader>
<ChildFields />
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setCreateOpen(false)}
disabled={isPending}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
Speichern
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
) : (
<p className="text-xs text-muted-foreground">
Dein Account ist noch keinem Haushalt zugeordnet.
</p>
)}
<Dialog
open={!!editingChild}
onOpenChange={(open) => !open && setEditingChild(null)}
>
<DialogContent>
<form action={handleUpdate}>
<DialogHeader>
<DialogTitle>Kind bearbeiten</DialogTitle>
</DialogHeader>
{editingChild && <ChildFields child={editingChild} />}
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setEditingChild(null)}
disabled={isPending}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
Aktualisieren
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}
function ChildFields({ child }: { child?: MyChild }) {
return (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="child-firstName">Vorname</Label>
<Input
id="child-firstName"
name="firstName"
defaultValue={child?.firstName ?? ""}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="child-lastName">Nachname</Label>
<Input
id="child-lastName"
name="lastName"
defaultValue={child?.lastName ?? ""}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="child-dateOfBirth">Geburtsdatum</Label>
<Input
id="child-dateOfBirth"
name="dateOfBirth"
type="date"
defaultValue={child?.dateOfBirth ?? ""}
/>
</div>
</div>
);
}
+247 -7
View File
@@ -1,24 +1,264 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
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
const childSchema = z.object({
firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
dateOfBirth: z.string().optional(),
});
const familySchema = z.object({
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
});
const contactSchema = z.object({
phone: z.string().trim().max(50).optional(),
street: z.string().trim().max(120).optional(),
postalCode: z.string().trim().max(20).optional(),
city: z.string().trim().max(100).optional(),
});
function parseDateInput(value?: string) {
if (!value) return null;
const date = new Date(`${value}T00:00:00`);
return Number.isNaN(date.getTime()) ? null : date;
}
async function requireOwnFamilyChild(
childId: string,
familyId: string | null,
kitaId: string,
) {
if (!familyId) return null;
return prisma.child.findFirst({
where: { id: childId, familyId, kitaId },
});
}
export async function createMyChild(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = childSchema.safeParse(rawPayload);
if (!session.user.familyId) {
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
}
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
try {
await prisma.child.create({
data: {
kitaId: session.user.kitaId,
familyId: session.user.familyId,
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
dateOfBirth: parseDateInput(parsed.data.dateOfBirth),
},
});
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/families");
return { success: true };
} catch (error) {
console.error("Fehler beim Anlegen des Kindes:", error);
return { error: "Das Kind konnte nicht angelegt werden." };
}
}
export async function createMyFamily(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = familySchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Bitte gib einen gültigen Familiennamen ein." };
}
if (session.user.familyId) {
return { error: "Dein Account ist bereits einem Haushalt zugeordnet." };
}
try {
await prisma.$transaction(async (tx) => {
const family = await tx.family.create({
data: {
kitaId: session.user.kitaId,
name: parsed.data.familyName,
},
});
await tx.user.update({
where: { id: session.user.id },
data: { familyId: family.id },
});
});
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/families");
revalidatePath("/dashboard/adressbuch");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Anlegen des eigenen Haushalts:", error);
return { error: "Der Haushalt konnte nicht angelegt werden." };
}
}
export async function updateMyContact(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = contactSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Kontaktdaten." };
}
try {
await prisma.user.update({
where: { id: session.user.id },
data: {
phone: parsed.data.phone || null,
street: parsed.data.street || null,
postalCode: parsed.data.postalCode || null,
city: parsed.data.city || null,
},
});
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard");
revalidatePath("/dashboard/adressbuch");
return { success: true };
} catch (error) {
console.error("Fehler beim Aktualisieren der Kontaktdaten:", error);
return { error: "Kontaktdaten konnten nicht gespeichert werden." };
}
}
export async function updateMyChild(childId: string, rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = childSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
try {
const child = await requireOwnFamilyChild(
childId,
session.user.familyId,
session.user.kitaId,
);
if (!child) {
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
}
await prisma.child.update({
where: { id: childId },
data: {
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
dateOfBirth: parseDateInput(parsed.data.dateOfBirth),
},
});
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/families");
return { success: true };
} catch (error) {
console.error("Fehler beim Aktualisieren des Kindes:", error);
return { error: "Das Kind konnte nicht aktualisiert werden." };
}
}
export async function deleteMyChild(childId: string) {
const session = await requireKitaSession();
try {
const child = await requireOwnFamilyChild(
childId,
session.user.familyId,
session.user.kitaId,
);
if (!child) {
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
}
await prisma.child.delete({
where: { id: childId },
});
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/families");
return { success: true };
} catch (error) {
console.error("Fehler beim Entfernen des Kindes:", error);
return { error: "Das Kind konnte nicht entfernt werden." };
}
}
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({
const user = await prisma.user.findFirst({
where: {
id: session.user.id,
kitaId: session.user.kitaId,
},
select: {
id: true,
familyId: true,
},
});
// The client component will trigger the actual NextAuth client signOut,
// but returning success indicates the DB deletion was successful.
if (!user) {
return { error: "Account wurde nicht gefunden." };
}
if (!user.familyId) {
await prisma.user.delete({
where: { id: user.id },
});
return { success: true };
}
const family = await prisma.family.findFirst({
where: {
id: user.familyId,
kitaId: session.user.kitaId,
},
select: {
id: true,
users: {
select: { id: true },
},
},
});
if (!family) {
return { error: "Haushalt wurde nicht gefunden." };
}
if (family.users.length <= 1) {
await prisma.family.delete({
where: { id: family.id },
});
return { success: true };
}
await prisma.user.delete({
where: { id: user.id },
});
return { success: true };
} catch (error) {
console.error("Fehler beim Löschen des Accounts:", error);
+108 -21
View File
@@ -2,11 +2,22 @@ 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 {
Baby,
CalendarHeart,
Home,
Mail,
Phone,
Shield,
UserCircle,
} 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";
import { MyChildrenManager } from "./_components/my-children-manager";
import { CreateFamilyDialog } from "./_components/create-family-dialog";
import { ContactForm } from "./_components/contact-form";
export const metadata = { title: "Mein Profil · Kita-Planer" };
@@ -15,12 +26,41 @@ export default async function ProfilPage() {
const user = await prisma.user.findUniqueOrThrow({
where: { id: session.user.id },
include: {
childLinks: {
include: { child: true },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
familyId: true,
phone: true,
street: true,
postalCode: true,
city: true,
createdAt: true,
family: {
select: {
name: true,
children: {
select: {
id: true,
firstName: true,
lastName: true,
dateOfBirth: true,
},
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
},
},
},
dutyAssignments: {
include: { duty: true },
select: {
id: true,
duty: {
select: {
name: true,
},
},
},
},
},
});
@@ -56,12 +96,39 @@ export default async function ProfilPage() {
</div>
<div className="font-medium">{user.email}</div>
</div>
<div>
<div className="text-sm text-muted-foreground flex items-center gap-1">
<Phone className="h-3.5 w-3.5" /> Telefon
</div>
<div className="font-medium">
{user.phone ?? "Noch nicht hinterlegt"}
</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>
<div className="text-sm text-muted-foreground flex items-center gap-1">
<Home className="h-3.5 w-3.5" /> Haushalt
</div>
{user.family ? (
<div className="font-medium">{user.family.name}</div>
) : (
<div className="mt-2 space-y-3 rounded-md border border-dashed p-3">
<p className="text-sm text-muted-foreground">
Dein Account ist noch keinem Haushalt zugeordnet. Lege hier
deinen eigenen Haushalt an, wenn du selbst Elternteil in der
Kita bist.
</p>
<CreateFamilyDialog
defaultFamilyName={`Familie ${user.lastName}`}
/>
</div>
)}
</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
@@ -74,6 +141,29 @@ export default async function ProfilPage() {
</Card>
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Phone className="h-5 w-5" />
Kontaktdaten
</CardTitle>
<CardDescription>
Diese Angaben helfen bei Notdienst, Adressbuch und Abstimmung in
der Kita.
</CardDescription>
</CardHeader>
<CardContent>
<ContactForm
contact={{
phone: user.phone,
street: user.street,
postalCode: user.postalCode,
city: user.city,
}}
/>
</CardContent>
</Card>
{/* Kinder */}
<Card>
<CardHeader>
@@ -83,19 +173,17 @@ export default async function ProfilPage() {
</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>
)}
<MyChildrenManager
items={(user.family?.children ?? []).map((child) => ({
id: child.id,
firstName: child.firstName,
lastName: child.lastName,
dateOfBirth: child.dateOfBirth
? child.dateOfBirth.toISOString().slice(0, 10)
: null,
}))}
canManage={!!user.familyId}
/>
</CardContent>
</Card>
@@ -124,12 +212,11 @@ export default async function ProfilPage() {
</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.
Hier kannst du dein Profil und je nach Haushaltssituation auch die
zugehörigen Familiendaten DSGVO-konform löschen.
</CardDescription>
</CardHeader>
<CardContent>