diff --git a/next.config.ts b/next.config.ts
index e9ffa30..886e38f 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,7 +1,15 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
+ images: {
+ remotePatterns: [
+ {
+ protocol: "https",
+ hostname: "images.unsplash.com",
+ pathname: "/**",
+ },
+ ],
+ },
};
export default nextConfig;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index a943947..a6e1f33 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -63,6 +63,11 @@ enum TerminStatus {
CANCELLED
}
+enum EventParticipationStatus {
+ ATTENDING
+ NOT_ATTENDING
+}
+
enum NotdienstPlanStatus {
DRAFT
PUBLISHED
@@ -121,6 +126,7 @@ model Kita {
invitations Invitation[]
termine Termin[]
mitbringselItems MitbringselItem[]
+ eventParticipations EventParticipation[]
notdienstPlans NotdienstPlan[]
notdienstAvailabilities NotdienstAvailability[]
notdienstAssignments NotdienstAssignment[]
@@ -248,6 +254,7 @@ model Child {
notdienstAvailabilities NotdienstAvailability[]
notdienstAssignments NotdienstAssignment[]
absences Absence[]
+ eventParticipations EventParticipation[]
@@index([kitaId])
@@index([familyId])
@@ -490,6 +497,11 @@ model Termin {
/// Mitbringliste pro Termin aktivierbar (Modul 2, "Flexible Mitbring-Listen")
mitbringselListEnabled Boolean @default(false)
+ /// Teilnahme-Abfrage für Ausflüge und besondere Events.
+ requiresRsvp Boolean @default(false)
+ rsvpDeadline DateTime?
+ participantInfo String?
+
createdById String?
createdBy User? @relation("TerminCreator", fields: [createdById], references: [id], onDelete: SetNull)
@@ -502,12 +514,35 @@ model Termin {
updatedAt DateTime @updatedAt
mitbringselItems MitbringselItem[]
+ participations EventParticipation[]
@@index([kitaId, startDate])
@@index([kitaId, status])
@@map("termine")
}
+model EventParticipation {
+ id String @id @default(cuid())
+ kitaId String
+ kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
+
+ eventId String
+ event Termin @relation(fields: [eventId], references: [id], onDelete: Cascade)
+
+ childId String
+ child Child @relation(fields: [childId], references: [id], onDelete: Cascade)
+
+ status EventParticipationStatus
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([eventId, childId])
+ @@index([kitaId])
+ @@index([childId])
+ @@map("event_participations")
+}
+
model MitbringselItem {
id String @id @default(cuid())
kitaId String
diff --git a/src/actions/rsvp.ts b/src/actions/rsvp.ts
new file mode 100644
index 0000000..83eede9
--- /dev/null
+++ b/src/actions/rsvp.ts
@@ -0,0 +1,156 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import {
+ EventParticipationStatus,
+ TerminStatus,
+ UserRole,
+} from "@prisma/client";
+import { z } from "zod";
+
+import { requireKitaSession, requireRole } from "@/lib/auth-utils";
+import { prisma } from "@/lib/prisma";
+
+const submitRsvpSchema = z.object({
+ eventId: z.string().min(1),
+ childId: z.string().min(1),
+ status: z.nativeEnum(EventParticipationStatus),
+});
+
+export type EventParticipantDto = {
+ childId: string;
+ childName: string;
+ familyName: string;
+ status: EventParticipationStatus | null;
+};
+
+export async function submitEventRsvp(rawPayload: unknown) {
+ const session = await requireKitaSession();
+ const parsed = submitRsvpSchema.safeParse(rawPayload);
+
+ if (!parsed.success) {
+ return { error: "Ungültige Eingabedaten." };
+ }
+
+ if (!session.user.familyId) {
+ return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
+ }
+
+ const { eventId, childId, status } = parsed.data;
+
+ const event = await prisma.termin.findFirst({
+ where: {
+ id: eventId,
+ kitaId: session.user.kitaId,
+ },
+ select: {
+ id: true,
+ status: true,
+ requiresRsvp: true,
+ rsvpDeadline: true,
+ startDate: true,
+ },
+ });
+
+ if (!event || !event.requiresRsvp || event.status !== TerminStatus.CONFIRMED) {
+ return { error: "Für diesen Termin ist keine Rückmeldung aktiv." };
+ }
+
+ const deadline = event.rsvpDeadline ?? event.startDate;
+ if (deadline < new Date()) {
+ return { error: "Die Rückmeldefrist ist bereits abgelaufen." };
+ }
+
+ const child = await prisma.child.findFirst({
+ where: {
+ id: childId,
+ kitaId: session.user.kitaId,
+ familyId: session.user.familyId,
+ active: true,
+ },
+ select: { id: true },
+ });
+
+ if (!child) {
+ return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
+ }
+
+ await prisma.eventParticipation.upsert({
+ where: {
+ eventId_childId: {
+ eventId,
+ childId,
+ },
+ },
+ create: {
+ kitaId: session.user.kitaId,
+ eventId,
+ childId,
+ status,
+ },
+ update: {
+ status,
+ },
+ });
+
+ revalidatePath("/dashboard");
+ revalidatePath("/dashboard/kalender");
+ revalidatePath("/dashboard/admin/kalender");
+ return { success: true };
+}
+
+export async function getEventParticipants(
+ eventId: string,
+): Promise<{ participants?: EventParticipantDto[]; error?: string }> {
+ const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
+ const kitaId = session.user.kitaId;
+
+ if (!kitaId) {
+ return { error: "Kein Mandant zugeordnet." };
+ }
+
+ const event = await prisma.termin.findFirst({
+ where: { id: eventId, kitaId },
+ select: { id: true },
+ });
+
+ if (!event) {
+ return { error: "Termin wurde nicht gefunden." };
+ }
+
+ const children = await prisma.child.findMany({
+ where: {
+ kitaId,
+ active: true,
+ },
+ select: {
+ id: true,
+ firstName: true,
+ lastName: true,
+ family: {
+ select: {
+ name: true,
+ },
+ },
+ eventParticipations: {
+ where: { eventId },
+ select: { status: true },
+ take: 1,
+ },
+ },
+ orderBy: [
+ { family: { name: "asc" } },
+ { lastName: "asc" },
+ { firstName: "asc" },
+ ],
+ });
+
+ return {
+ participants: children.map((child) => ({
+ childId: child.id,
+ childName: `${child.firstName} ${child.lastName}`,
+ familyName: child.family.name,
+ status: child.eventParticipations[0]?.status ?? null,
+ })),
+ };
+}
diff --git a/src/app/dashboard/admin/kalender/admin-calendar-manager.tsx b/src/app/dashboard/admin/kalender/admin-calendar-manager.tsx
new file mode 100644
index 0000000..4ea24a1
--- /dev/null
+++ b/src/app/dashboard/admin/kalender/admin-calendar-manager.tsx
@@ -0,0 +1,608 @@
+"use client";
+
+import { useMemo, useState, useTransition } from "react";
+import {
+ EventParticipationStatus,
+ TerminStatus,
+ TerminType,
+} from "@prisma/client";
+import { format } from "date-fns";
+import { de } from "date-fns/locale";
+import {
+ CalendarPlus,
+ Check,
+ Clock,
+ HelpCircle,
+ Pencil,
+ Users,
+ X,
+} from "lucide-react";
+import { toast } from "sonner";
+
+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,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ createTerminAdmin,
+ updateTerminAdmin,
+} from "../../kalender/actions";
+
+type AdminTerminDto = {
+ id: string;
+ title: string;
+ description: string | null;
+ type: TerminType;
+ status: TerminStatus;
+ startDate: string;
+ endDate: string;
+ allDay: boolean;
+ mitbringselListEnabled: boolean;
+ requiresRsvp: boolean;
+ rsvpDeadline: string | null;
+ participantInfo: string | null;
+ participations: {
+ childId: string;
+ status: EventParticipationStatus;
+ }[];
+};
+
+type ChildDto = {
+ id: string;
+ name: string;
+ familyName: string;
+};
+
+export function AdminCalendarManager({
+ termine,
+ childRows,
+}: {
+ termine: AdminTerminDto[];
+ childRows: ChildDto[];
+}) {
+ return (
+
+
+
+
+ Event-Anmeldungen & Ausflüge
+
+
+ Termine verwalten, RSVP aktivieren und den Rücklauf pro Kind
+ verfolgen.
+
+
+
+
+
+ {termine.length === 0 ? (
+
+
+
Noch keine Termine
+
+ Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
+
+
+ ) : (
+
+ {termine.map((termin) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function AdminTerminCard({
+ termin,
+ childRows,
+}: {
+ termin: AdminTerminDto;
+ childRows: ChildDto[];
+}) {
+ const stats = useMemo(() => getStats(termin, childRows), [termin, childRows]);
+
+ return (
+
+
+
+
+
+
+ {getTypeLabel(termin.type)}
+
+
+ {getStatusLabel(termin.status)}
+
+ {termin.requiresRsvp && (
+ RSVP aktiv
+ )}
+
+
{termin.title}
+
+
+ {format(new Date(termin.startDate), "dd.MM.yyyy", {
+ locale: de,
+ })}
+ {!termin.allDay && (
+ <>
+ ·
+
+ {format(new Date(termin.startDate), "HH:mm", {
+ locale: de,
+ })}{" "}
+ bis{" "}
+ {format(new Date(termin.endDate), "HH:mm", {
+ locale: de,
+ })}
+
+ >
+ )}
+
+
+
+
+
+
+
+ {termin.description && (
+
+ {termin.description}
+
+ )}
+
+ {termin.requiresRsvp ? (
+
+
+
+
+ {stats.attending} Zugesagt / {stats.notAttending} Abgesagt /{" "}
+ {stats.pending} Ausstehend
+
+
+ Frist:{" "}
+ {termin.rsvpDeadline
+ ? format(new Date(termin.rsvpDeadline), "dd.MM.yyyy HH:mm", {
+ locale: de,
+ })
+ : "bis Terminbeginn"}
+
+
+
+
+
+ {termin.participantInfo && (
+
+ Hinweis für Teilnehmer:
+ {termin.participantInfo}
+
+ )}
+
+
+
+
+ Kind
+ Familie
+ Status
+
+
+
+ {childRows.map((child) => {
+ const status = stats.statusByChildId.get(child.id) ?? null;
+ return (
+
+ {child.name}
+ {child.familyName}
+
+
+
+
+ );
+ })}
+
+
+
+ ) : (
+
+ Für diesen Termin ist keine Teilnahme-Abfrage aktiv.
+
+ )}
+
+
+ );
+}
+
+function TerminFormDialog({
+ mode,
+ termin,
+}: {
+ mode: "create" | "edit";
+ termin?: AdminTerminDto;
+}) {
+ const [open, setOpen] = useState(false);
+ const [requiresRsvp, setRequiresRsvp] = useState(
+ termin?.requiresRsvp ?? false,
+ );
+ const [isPending, startTransition] = useTransition();
+
+ function handleAction(formData: FormData) {
+ const startDate = new Date(String(formData.get("startDate")));
+ const endDate = new Date(String(formData.get("endDate")));
+ const deadlineRaw = String(formData.get("rsvpDeadline") ?? "");
+ const hasRsvp = formData.get("requiresRsvp") === "on";
+
+ const payload = {
+ title: String(formData.get("title") ?? ""),
+ description: String(formData.get("description") ?? ""),
+ type: formData.get("type") as TerminType,
+ startDate: startDate.toISOString(),
+ endDate: endDate.toISOString(),
+ allDay: formData.get("allDay") === "on",
+ mitbringselListEnabled: formData.get("mitbringselListEnabled") === "on",
+ requiresRsvp: hasRsvp,
+ rsvpDeadline:
+ hasRsvp && deadlineRaw ? new Date(deadlineRaw).toISOString() : null,
+ participantInfo: String(formData.get("participantInfo") ?? ""),
+ };
+
+ startTransition(async () => {
+ const result =
+ mode === "edit" && termin
+ ? await updateTerminAdmin(termin.id, payload)
+ : await createTerminAdmin(payload);
+
+ if (result.error) {
+ toast.error(result.error);
+ return;
+ }
+
+ toast.success(
+ mode === "edit" ? "Termin aktualisiert." : "Termin angelegt.",
+ );
+ setOpen(false);
+ });
+ }
+
+ return (
+
+ );
+}
+
+function RsvpStatusBadge({
+ status,
+}: {
+ status: EventParticipationStatus | null;
+}) {
+ if (status === EventParticipationStatus.ATTENDING) {
+ return (
+
+
+ Zugesagt
+
+ );
+ }
+
+ if (status === EventParticipationStatus.NOT_ATTENDING) {
+ return (
+
+
+ Abgesagt
+
+ );
+ }
+
+ return (
+
+
+ Ausstehend
+
+ );
+}
+
+function getStats(termin: AdminTerminDto, children: ChildDto[]) {
+ const statusByChildId = new Map(
+ termin.participations.map((participation) => [
+ participation.childId,
+ participation.status,
+ ]),
+ );
+
+ let attending = 0;
+ let notAttending = 0;
+
+ children.forEach((child) => {
+ const status = statusByChildId.get(child.id);
+ if (status === EventParticipationStatus.ATTENDING) attending += 1;
+ if (status === EventParticipationStatus.NOT_ATTENDING) notAttending += 1;
+ });
+
+ return {
+ attending,
+ notAttending,
+ pending: Math.max(children.length - attending - notAttending, 0),
+ statusByChildId,
+ };
+}
+
+function toDatetimeLocalValue(value: string) {
+ const date = new Date(value);
+ const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
+ return localDate.toISOString().slice(0, 16);
+}
+
+function getStatusVariant(
+ status: TerminStatus,
+): "default" | "secondary" | "destructive" | "outline" | "success" | "warning" {
+ switch (status) {
+ case TerminStatus.CONFIRMED:
+ return "success";
+ case TerminStatus.PENDING:
+ return "warning";
+ case TerminStatus.REJECTED:
+ case TerminStatus.CANCELLED:
+ return "destructive";
+ default:
+ return "outline";
+ }
+}
+
+function getStatusLabel(status: TerminStatus) {
+ switch (status) {
+ case TerminStatus.CONFIRMED:
+ return "Bestätigt";
+ case TerminStatus.PENDING:
+ return "Ausstehend";
+ case TerminStatus.REJECTED:
+ return "Abgelehnt";
+ case TerminStatus.CANCELLED:
+ return "Abgesagt";
+ }
+}
+
+function getTypeBadgeClass(type: TerminType) {
+ switch (type) {
+ case TerminType.KITA_FEST:
+ case TerminType.MITMACH_TAG:
+ return "border-transparent bg-amber-500 text-white";
+ case TerminType.SCHLIESSTAG:
+ case TerminType.TEAMTAG:
+ return "border-transparent bg-rose-500 text-white";
+ case TerminType.GEBURTSTAG_INTERN:
+ case TerminType.GEBURTSTAG_EXTERN:
+ return "border-transparent bg-blue-500 text-white";
+ case TerminType.ELTERNABEND:
+ case TerminType.MITGLIEDERVERSAMMLUNG:
+ case TerminType.ELTERNCAFE:
+ return "border-transparent bg-purple-500 text-white";
+ default:
+ return "border-slate-300 bg-slate-100 text-slate-800";
+ }
+}
+
+function getTypeLabel(type: TerminType) {
+ switch (type) {
+ case TerminType.KITA_FEST:
+ return "Kita-Fest";
+ case TerminType.SCHLIESSTAG:
+ return "Schließtag";
+ case TerminType.TEAMTAG:
+ return "Teamtag";
+ case TerminType.MITMACH_TAG:
+ return "Mitmach-Tag";
+ case TerminType.ELTERNABEND:
+ return "Elternabend";
+ case TerminType.MITGLIEDERVERSAMMLUNG:
+ return "Mitgliederversammlung";
+ case TerminType.ELTERNCAFE:
+ return "Elterncafe";
+ case TerminType.GEBURTSTAG_INTERN:
+ return "Geburtstag Intern";
+ case TerminType.GEBURTSTAG_EXTERN:
+ return "Raumanfrage Extern";
+ case TerminType.SONSTIGES:
+ return "Sonstiges";
+ }
+}
diff --git a/src/app/dashboard/admin/kalender/page.tsx b/src/app/dashboard/admin/kalender/page.tsx
new file mode 100644
index 0000000..9fcc6bb
--- /dev/null
+++ b/src/app/dashboard/admin/kalender/page.tsx
@@ -0,0 +1,80 @@
+import { UserRole } from "@prisma/client";
+
+import { requireRole } from "@/lib/auth-utils";
+import { prisma } from "@/lib/prisma";
+import { AdminCalendarManager } from "./admin-calendar-manager";
+
+export const metadata = { title: "Event-Anmeldungen · Kita-Planer" };
+
+export default async function AdminKalenderPage() {
+ const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
+ const kitaId = session.user.kitaId;
+
+ if (!kitaId) {
+ return Kein Mandant zugeordnet.
;
+ }
+
+ const [termine, children] = await Promise.all([
+ prisma.termin.findMany({
+ where: { kitaId },
+ select: {
+ id: true,
+ title: true,
+ description: true,
+ type: true,
+ status: true,
+ startDate: true,
+ endDate: true,
+ allDay: true,
+ mitbringselListEnabled: true,
+ requiresRsvp: true,
+ rsvpDeadline: true,
+ participantInfo: true,
+ participations: {
+ select: {
+ childId: true,
+ status: true,
+ },
+ },
+ },
+ orderBy: { startDate: "desc" },
+ }),
+ prisma.child.findMany({
+ where: {
+ kitaId,
+ active: true,
+ },
+ select: {
+ id: true,
+ firstName: true,
+ lastName: true,
+ family: {
+ select: {
+ name: true,
+ },
+ },
+ },
+ orderBy: [
+ { family: { name: "asc" } },
+ { lastName: "asc" },
+ { firstName: "asc" },
+ ],
+ }),
+ ]);
+
+ return (
+ ({
+ ...termin,
+ startDate: termin.startDate.toISOString(),
+ endDate: termin.endDate.toISOString(),
+ rsvpDeadline: termin.rsvpDeadline?.toISOString() ?? null,
+ }))}
+ childRows={children.map((child) => ({
+ id: child.id,
+ name: `${child.firstName} ${child.lastName}`,
+ familyName: child.family.name,
+ }))}
+ />
+ );
+}
diff --git a/src/app/dashboard/kalender/_components/termin-list.tsx b/src/app/dashboard/kalender/_components/termin-list.tsx
index 3f3011d..06a395e 100644
--- a/src/app/dashboard/kalender/_components/termin-list.tsx
+++ b/src/app/dashboard/kalender/_components/termin-list.tsx
@@ -3,7 +3,7 @@
import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
-import { AlignLeft, Calendar, Clock, WashingMachine } from "lucide-react";
+import { AlertTriangle, AlignLeft, Calendar, Clock, WashingMachine } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@@ -13,6 +13,15 @@ import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useTransition } from "react";
import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
export type TerminListItemDto = {
kind: "termin";
@@ -25,6 +34,9 @@ export type TerminListItemDto = {
endDate: Date;
allDay: boolean;
mitbringselListEnabled: boolean;
+ requiresRsvp: boolean;
+ participantInfo: string | null;
+ showParticipantInfo: boolean;
mitbringselItems?: {
id: string;
userId: string;
@@ -178,6 +190,8 @@ function TerminCard({
)}
+
+
{/* Admin Toggle for Mitbringsel-List */}
{isAdmin && termin.status === TerminStatus.CONFIRMED && (
@@ -209,6 +223,58 @@ function TerminCard({
);
}
+function TerminDetailDialog({ termin }: { termin: TerminListItemDto }) {
+ return (
+
+ );
+}
+
function getBadgeVariant(type: TerminType): "default" | "secondary" | "destructive" | "outline" {
switch (type) {
case TerminType.KITA_FEST:
diff --git a/src/app/dashboard/kalender/actions.ts b/src/app/dashboard/kalender/actions.ts
index 46ce4f9..55638b3 100644
--- a/src/app/dashboard/kalender/actions.ts
+++ b/src/app/dashboard/kalender/actions.ts
@@ -12,14 +12,48 @@ import { prisma } from "@/lib/prisma";
// =====================================================================
const terminSchema = z.object({
- title: z.string().min(2, "Titel muss mindestens 2 Zeichen lang sein"),
- description: z.string().optional(),
+ title: z.string().trim().min(2, "Titel muss mindestens 2 Zeichen lang sein"),
+ description: z.string().trim().optional(),
type: z.nativeEnum(TerminType),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
allDay: z.boolean().default(false),
+ mitbringselListEnabled: z.boolean().default(false),
+ requiresRsvp: z.boolean().default(false),
+ rsvpDeadline: z.string().datetime().nullable().optional(),
+ participantInfo: z.string().trim().max(1000).optional(),
});
+function buildTerminData(data: z.infer
) {
+ const startDate = new Date(data.startDate);
+ const endDate = new Date(data.endDate);
+
+ if (endDate < startDate) {
+ return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
+ }
+
+ return {
+ data: {
+ title: data.title,
+ description: data.description || null,
+ type: data.type,
+ startDate,
+ endDate,
+ allDay: data.allDay,
+ mitbringselListEnabled: data.mitbringselListEnabled,
+ requiresRsvp: data.requiresRsvp,
+ rsvpDeadline:
+ data.requiresRsvp && data.rsvpDeadline
+ ? new Date(data.rsvpDeadline)
+ : null,
+ participantInfo:
+ data.requiresRsvp && data.participantInfo
+ ? data.participantInfo
+ : null,
+ },
+ };
+}
+
export async function createTerminRequest(rawPayload: unknown) {
const session = await requireKitaSession();
@@ -29,18 +63,23 @@ export async function createTerminRequest(rawPayload: unknown) {
}
const data = parsed.data;
+ const terminData = buildTerminData(data);
+
+ if ("error" in terminData) {
+ return { error: terminData.error };
+ }
try {
await prisma.termin.create({
data: {
kitaId: session.user.kitaId,
createdById: session.user.id,
- title: data.title,
- description: data.description,
- type: data.type,
- startDate: new Date(data.startDate),
- endDate: new Date(data.endDate),
- allDay: data.allDay,
+ title: terminData.data.title,
+ description: terminData.data.description,
+ type: terminData.data.type,
+ startDate: terminData.data.startDate,
+ endDate: terminData.data.endDate,
+ allDay: terminData.data.allDay,
status: TerminStatus.PENDING,
},
});
@@ -66,18 +105,18 @@ export async function createTerminAdmin(rawPayload: unknown) {
}
const data = parsed.data;
+ const terminData = buildTerminData(data);
+
+ if ("error" in terminData) {
+ return { error: terminData.error };
+ }
try {
await prisma.termin.create({
data: {
kitaId,
createdById: session.user.id,
- title: data.title,
- description: data.description,
- type: data.type,
- startDate: new Date(data.startDate),
- endDate: new Date(data.endDate),
- allDay: data.allDay,
+ ...terminData.data,
status: TerminStatus.CONFIRMED,
approvedById: session.user.id,
approvedAt: new Date(),
@@ -85,6 +124,7 @@ export async function createTerminAdmin(rawPayload: unknown) {
});
revalidatePath("/dashboard/kalender");
+ revalidatePath("/dashboard/admin/kalender");
return { success: true };
} catch (error) {
console.error("Fehler beim Erstellen des Termins:", error);
@@ -92,6 +132,43 @@ export async function createTerminAdmin(rawPayload: unknown) {
}
}
+export async function updateTerminAdmin(terminId: string, 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) {
+ return { error: "Ungültige Eingabedaten." };
+ }
+
+ const terminData = buildTerminData(parsed.data);
+ if ("error" in terminData) {
+ return { error: terminData.error };
+ }
+
+ try {
+ await prisma.termin.update({
+ where: {
+ id: terminId,
+ kitaId,
+ },
+ data: terminData.data,
+ });
+
+ revalidatePath("/dashboard/kalender");
+ revalidatePath("/dashboard/admin/kalender");
+ revalidatePath("/dashboard");
+ return { success: true };
+ } catch (error) {
+ console.error("Fehler beim Aktualisieren des Termins:", error);
+ return { error: "Termin konnte nicht aktualisiert werden." };
+ }
+}
+
export async function approveTermin(terminId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
@@ -113,6 +190,7 @@ export async function approveTermin(terminId: string) {
});
revalidatePath("/dashboard/kalender");
+ revalidatePath("/dashboard/admin/kalender");
return { success: true };
} catch (error) {
console.error("Fehler beim Bestätigen des Termins:", error);
@@ -142,6 +220,7 @@ export async function rejectTermin(terminId: string, reason?: string) {
});
revalidatePath("/dashboard/kalender");
+ revalidatePath("/dashboard/admin/kalender");
return { success: true };
} catch (error) {
console.error("Fehler beim Ablehnen des Termins:", error);
@@ -168,6 +247,7 @@ export async function toggleMitbringselList(terminId: string, enabled: boolean)
});
revalidatePath("/dashboard/kalender");
+ revalidatePath("/dashboard/admin/kalender");
return { success: true };
} catch (error) {
console.error("Fehler beim Umschalten der Mitbring-Liste:", error);
diff --git a/src/app/dashboard/kalender/page.tsx b/src/app/dashboard/kalender/page.tsx
index a7e8f6d..3859b3e 100644
--- a/src/app/dashboard/kalender/page.tsx
+++ b/src/app/dashboard/kalender/page.tsx
@@ -1,4 +1,9 @@
-import { DutyAssignmentStatus, TerminStatus, UserRole } from "@prisma/client";
+import {
+ DutyAssignmentStatus,
+ EventParticipationStatus,
+ TerminStatus,
+ UserRole,
+} from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
@@ -16,6 +21,7 @@ export default async function KalenderPage({
const isAdmin =
session.user.role === UserRole.ADMIN ||
session.user.role === UserRole.KOORDINATOR;
+ const familyIdForParticipation = session.user.familyId ?? "__no_family__";
const currentTab = searchParams.tab || "übersicht";
@@ -34,6 +40,8 @@ export default async function KalenderPage({
endDate: true,
allDay: true,
mitbringselListEnabled: true,
+ requiresRsvp: true,
+ participantInfo: true,
mitbringselItems: {
select: {
id: true,
@@ -47,6 +55,17 @@ export default async function KalenderPage({
},
},
},
+ participations: {
+ where: {
+ child: {
+ familyId: familyIdForParticipation,
+ },
+ },
+ select: {
+ childId: true,
+ status: true,
+ },
+ },
},
orderBy: { startDate: "asc" },
});
@@ -67,6 +86,8 @@ export default async function KalenderPage({
endDate: true,
allDay: true,
mitbringselListEnabled: true,
+ requiresRsvp: true,
+ participantInfo: true,
mitbringselItems: {
select: {
id: true,
@@ -80,6 +101,17 @@ export default async function KalenderPage({
},
},
},
+ participations: {
+ where: {
+ child: {
+ familyId: familyIdForParticipation,
+ },
+ },
+ select: {
+ childId: true,
+ status: true,
+ },
+ },
},
orderBy: { startDate: "asc" },
});
@@ -113,10 +145,22 @@ export default async function KalenderPage({
...confirmedTermineRows.map((termin) => ({
...termin,
kind: "termin" as const,
+ showParticipantInfo:
+ isAdmin ||
+ termin.participations.some(
+ (participation) =>
+ participation.status === EventParticipationStatus.ATTENDING,
+ ),
})),
...myPendingTermineRows.map((termin) => ({
...termin,
kind: "termin" as const,
+ showParticipantInfo:
+ isAdmin ||
+ termin.participations.some(
+ (participation) =>
+ participation.status === EventParticipationStatus.ATTENDING,
+ ),
})),
...dutyAssignmentRows.map((assignment) => ({
kind: "duty" as const,
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index e619090..379ac45 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -2,7 +2,7 @@ import Link from "next/link";
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 { DutyAssignmentStatus, UserRole, NotdienstPlanStatus, TerminStatus } from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
@@ -19,11 +19,13 @@ import { OnboardingDialog } from "@/components/OnboardingDialog";
import { AlertButton } from "./alert-button";
import { AbsenceCard } from "./absence-card";
import { NewsTicker } from "./news-ticker";
+import { RsvpActionCard } from "./rsvp-action-card";
export const metadata = { title: "Übersicht · Kita-Planer" };
export default async function DashboardPage() {
const session = await requireKitaSession();
+ const now = new Date();
const kita = await prisma.kita.findUniqueOrThrow({
where: { id: session.user.kitaId },
@@ -172,6 +174,62 @@ export default async function DashboardPage() {
}),
]);
+ const familyChildIds = absenceChildren.map((child) => child.id);
+ const openRsvpTermine =
+ session.user.familyId && familyChildIds.length > 0
+ ? await prisma.termin.findMany({
+ where: {
+ kitaId: session.user.kitaId,
+ status: TerminStatus.CONFIRMED,
+ requiresRsvp: true,
+ OR: [
+ { rsvpDeadline: { gt: now } },
+ { rsvpDeadline: null, startDate: { gt: now } },
+ ],
+ },
+ select: {
+ id: true,
+ title: true,
+ startDate: true,
+ rsvpDeadline: true,
+ participations: {
+ where: {
+ childId: { in: familyChildIds },
+ },
+ select: {
+ childId: true,
+ status: true,
+ },
+ },
+ },
+ orderBy: { startDate: "asc" },
+ take: 5,
+ })
+ : [];
+
+ const rsvpActionEvents = openRsvpTermine
+ .map((termin) => {
+ const statusByChildId = new Map(
+ termin.participations.map((participation) => [
+ participation.childId,
+ participation.status,
+ ]),
+ );
+
+ return {
+ id: termin.id,
+ title: termin.title,
+ startDate: termin.startDate.toISOString(),
+ rsvpDeadline: termin.rsvpDeadline?.toISOString() ?? null,
+ children: absenceChildren.map((child) => ({
+ id: child.id,
+ name: `${child.firstName} ${child.lastName}`,
+ status: statusByChildId.get(child.id) ?? null,
+ })),
+ };
+ })
+ .filter((termin) => termin.children.some((child) => !child.status));
+
const isAdmin =
session.user.role === UserRole.ADMIN ||
session.user.role === UserRole.SUPERADMIN;
@@ -204,6 +262,8 @@ export default async function DashboardPage() {
+
+
({
id: announcement.id,
@@ -307,9 +367,9 @@ export default async function DashboardPage() {
{kita.terminModuleEnabled && (
)}
diff --git a/src/app/dashboard/rsvp-action-card.tsx b/src/app/dashboard/rsvp-action-card.tsx
new file mode 100644
index 0000000..2a130ff
--- /dev/null
+++ b/src/app/dashboard/rsvp-action-card.tsx
@@ -0,0 +1,204 @@
+"use client";
+
+import { useMemo, useState, useTransition } from "react";
+import { EventParticipationStatus } from "@prisma/client";
+import { format } from "date-fns";
+import { de } from "date-fns/locale";
+import { AlertTriangle, Check, X } from "lucide-react";
+import { toast } from "sonner";
+
+import { submitEventRsvp } from "@/actions/rsvp";
+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,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+
+export type DashboardRsvpEventDto = {
+ id: string;
+ title: string;
+ startDate: string;
+ rsvpDeadline: string | null;
+ children: {
+ id: string;
+ name: string;
+ status: EventParticipationStatus | null;
+ }[];
+};
+
+export function RsvpActionCard({ events }: { events: DashboardRsvpEventDto[] }) {
+ if (events.length === 0) return null;
+
+ return (
+
+
+
+
+ Aktion erforderlich
+
+
+ Für diese Termine fehlt noch mindestens eine Rückmeldung deiner
+ Familie.
+
+
+
+ {events.map((event) => (
+
+
+
{event.title}
+
+ Rückmeldung bis{" "}
+ {format(
+ new Date(event.rsvpDeadline ?? event.startDate),
+ "dd.MM.yyyy HH:mm",
+ { locale: de },
+ )}
+
+
+
+
+ ))}
+
+
+ );
+}
+
+function RsvpDialog({ event }: { event: DashboardRsvpEventDto }) {
+ const [open, setOpen] = useState(false);
+ const [isPending, startTransition] = useTransition();
+ const initialSelection = useMemo(
+ () =>
+ Object.fromEntries(
+ event.children.map((child) => [child.id, child.status ?? ""]),
+ ) as Record,
+ [event.children],
+ );
+ const [selection, setSelection] = useState(initialSelection);
+
+ function submit() {
+ const missingChild = event.children.find((child) => !selection[child.id]);
+ if (missingChild) {
+ toast.error(`Bitte Rückmeldung für ${missingChild.name} auswählen.`);
+ return;
+ }
+
+ startTransition(async () => {
+ for (const child of event.children) {
+ const status = selection[child.id];
+ if (!status) continue;
+
+ const result = await submitEventRsvp({
+ eventId: event.id,
+ childId: child.id,
+ status,
+ });
+
+ if (result.error) {
+ toast.error(result.error);
+ return;
+ }
+ }
+
+ toast.success("Rückmeldung gespeichert.");
+ setOpen(false);
+ });
+ }
+
+ return (
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index be46ef6..98d8b59 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
+import Image from "next/image";
import Link from "next/link";
import { redirect } from "next/navigation";
import {
@@ -125,14 +126,19 @@ export default async function LandingPage() {
-
+
+
+
@@ -226,7 +232,7 @@ export default async function LandingPage() {
)}
-
+
{feature.title}
@@ -355,7 +361,7 @@ export default async function LandingPage() {
-
+
Anfrage senden
diff --git a/src/components/dashboard/sidebar-nav.tsx b/src/components/dashboard/sidebar-nav.tsx
index 8c29510..67e19a8 100644
--- a/src/components/dashboard/sidebar-nav.tsx
+++ b/src/components/dashboard/sidebar-nav.tsx
@@ -72,6 +72,13 @@ const navItems = [
exact: false,
allowedRoles: [UserRole.ADMIN],
},
+ {
+ href: "/dashboard/admin/kalender",
+ label: "Event-Anmeldungen",
+ icon: CalendarCheck2,
+ exact: false,
+ allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR],
+ },
{
href: "/dashboard/admin/abwesenheiten",
label: "Abwesenheiten",
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
index d6948d7..945c0c8 100644
--- a/src/components/ui/card.tsx
+++ b/src/components/ui/card.tsx
@@ -23,10 +23,14 @@ const CardHeader = React.forwardRef>(
- ({ className, ...props }, ref) => (
- & {
+ as?: "div" | "h2" | "h3" | "h4" | "h5" | "h6";
+};
+
+const CardTitle = React.forwardRef(
+ ({ className, as: Comp = "div", ...props }, ref) => (
+ }
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
{...props}
/>