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 ( + { + setOpen(nextOpen); + if (nextOpen) { + setRequiresRsvp(termin?.requiresRsvp ?? false); + } + }} + > + + + + +
+ + + {mode === "edit" ? "Termin bearbeiten" : "Termin anlegen"} + + + RSVP kann für Ausflüge und besondere Events aktiviert werden. + + + +
+
+ + +
+ +
+ +