diff --git a/docs/elternablaeufe.md b/docs/elternablaeufe.md new file mode 100644 index 0000000..9261157 --- /dev/null +++ b/docs/elternablaeufe.md @@ -0,0 +1,39 @@ +# Elternabläufe: Mitteilungen und Teilnahmeantworten + +## Verhalten + +- Die Übersicht zeigt offene Rückmeldungen und drei aktuelle Mitteilungen. Das Archiv unter `/dashboard/mitteilungen` enthält alle für das eigene Konto sichtbaren Meldungen, zwölf pro Seite. +- Termine können eine Antwort pro Kind oder pro Familie verlangen. Familienzusagen brauchen mindestens einen teilnehmenden Erwachsenen; Absagen speichern 0 Erwachsene. Beide Elternkonten bearbeiten dieselbe Antwort. +- Die Terminart schlägt bei neuen Terminen eine Antwortart vor. Die Verwaltung kann sie ändern, solange keine Antworten vorliegen. Danach verhindert auch die Server Action einen Wechsel. Normale Terminänderungen erhalten die bisherigen Teilnahmefelder. +- Kinderantworten werden für alle eigenen aktiven Kinder gemeinsam in einer Transaktion gespeichert. Eigene und fremde Kinder dürfen nicht gemischt werden. Frist, Terminstatus, Haushalt, Kita und aktiviertes Terminmodul werden serverseitig geprüft. +- Ohne eigene Rückmeldefrist gilt der Terminbeginn. Nach Fristablauf bleiben die Antworten im Kalender lesbar. Die Eingabe ist in Monatsansicht, Liste und Agenda erreichbar. +- Empfängergruppen sind Eltern, ErzieherInnen oder beide. Die Gruppe steuert App-Anzeige, Lesestatus-Aktion, Anhänge und optionalen E-Mail-Versand. Admins können sämtliche Meldungen der eigenen Kita zur Verwaltung sehen. Eltern mit Koordinationsrolle gehören mit ihrem Haushalt zur Elterngruppe; Erzieherkonten gehören zur Erziehergruppe. +- Teilnehmerhinweise werden nur bei einer Zusage des Haushalts oder für Admin/Koordination an den Kalender-Client übertragen. + +## Bestehende Daten und Schemaaktualisierung + +Das Projekt verwendet weiterhin `prisma db push`; es besitzt keine Prisma-Migrationshistorie. Der vorhandene Produktionsstart aktualisiert das Schema automatisch. Die neuen Pflichtfelder haben kompatible Defaults: bisherige Termine bleiben `CHILD`, bisherige Meldungen erhalten `ALLE`. Bestehende Kinderantworten werden nicht verändert. + +Für eine kontrollierte, einmalige Aktualisierung einer Datenbank mit dem bisherigen Schema liegt zusätzlich die additive SQL-Datei `prisma/updates/20261007_parent_flows.sql` vor. Sie ist eine Alternative zum ersten `db push`, kein nachträglich anzuwendender zweiter Schritt. Beispiel: + +```sh +psql "$DATABASE_URL" -v ON_ERROR_STOP=1 --single-transaction -f prisma/updates/20261007_parent_flows.sql +npm run prisma:generate +``` + +Die Datei gehört bewusst nicht in `prisma/migrations`: Eine Einführung von `prisma migrate deploy` würde zuerst eine Baseline für bereits existierende Datenbanken verlangen. + +## Verifikation + +```sh +npm run test:parent-flows +TEST_DATABASE_URL=postgresql://USER@127.0.0.1:PORT/TESTDB npm run test:parent-flows:integration +npx tsc --noEmit +npm run build +``` + +Die Integrationstests benötigen eine Node.js-Version mit experimentellem Modul-Mocking (hier geprüft mit Node.js 26), eine ausdrücklich über `TEST_DATABASE_URL` gesetzte lokale PostgreSQL-Testdatenbank und das aktuelle Schema. Sie legen eigene Kitas mit zufälligen IDs an und entfernen ausschließlich diese Testkitas wieder. Kein automatischer Zugriff auf `DATABASE_URL` ohne Testfreigabe. + +Die Tests prüfen Erwachsenenanzahl, gemischte Kinderantworten, gemeinsame Familienantworten, Fristablauf, Terminabsage, Moduldeaktivierung, Kita-/Haushaltsgrenzen, gesperrte Antwortart einschließlich paralleler erster Rückmeldung, erhaltene Terminoptionen sowie verweigerte Team-Meldungen und Anhänge für Eltern. E-Mail-Empfänger werden anhand derselben Gruppenregeln geprüft; es werden keine echten E-Mails verschickt. + +Zusätzlich im Browser geprüft: Familienzusage, unterschiedliche Antworten für zwei Kinder, verschwundene offene Hinweise, Lesen/Ändern der gemeinsamen Antwort aus einem zweiten Elternkonto, abgelaufene Eingabe, Kalender-Monats- und Agendaansicht, Lesestatus, Archivnavigation, Verwaltungsauswertung, gesperrte Antwortart und Eltern-/Erzieher-Empfängertrennung. Die Rückmeldeformulare und Übersicht wurden auch bei 390 Pixeln Breite visuell geprüft. diff --git a/package.json b/package.json index dbb2375..72a670b 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "prisma:migrate": "prisma migrate dev", "prisma:studio": "prisma studio", "db:seed": "NODE_ENV=development prisma db seed", - "db:seed:reset": "NODE_ENV=development prisma db seed -- --reset" + "db:seed:reset": "NODE_ENV=development prisma db seed -- --reset", + "test:parent-flows": "tsx --test tests/parent-flows.test.ts", + "test:parent-flows:integration": "tsx --experimental-test-module-mocks --test tests/parent-flows.integration.test.ts" }, "prisma": { "seed": "tsx prisma/seed.ts" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d071404..3bb3886 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -63,6 +63,17 @@ enum TerminStatus { CANCELLED } +enum RsvpMode { + CHILD + FAMILY +} + +enum AnnouncementAudience { + ELTERN + ERZIEHER + ALLE +} + enum EventParticipationStatus { ATTENDING NOT_ATTENDING @@ -156,6 +167,7 @@ model Kita { notdienstSwapRequests NotdienstSwapRequest[] staffProfiles StaffProfile[] staffHolidays StaffHoliday[] + familyEventParticipations FamilyEventParticipation[] @@map("kitas") } @@ -180,6 +192,7 @@ model Family { children Child[] dutyAssignments DutyAssignment[] notdienstSwapRequestsStarted NotdienstSwapRequest[] @relation("NotdienstSwapRequesterFamily") + familyEventParticipations FamilyEventParticipation[] @@index([kitaId]) @@map("families") @@ -668,8 +681,9 @@ model Announcement { kitaId String kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade) - title String - content String + title String + content String + audience AnnouncementAudience @default(ALLE) authorId String author User @relation("AnnouncementAuthor", fields: [authorId], references: [id], onDelete: Cascade) @@ -762,6 +776,7 @@ model Termin { /// Teilnahme-Abfrage für Ausflüge und besondere Events. requiresRsvp Boolean @default(false) + rsvpMode RsvpMode @default(CHILD) rsvpDeadline DateTime? participantInfo String? @@ -776,8 +791,9 @@ model Termin { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - mitbringselItems MitbringselItem[] - participations EventParticipation[] + mitbringselItems MitbringselItem[] + participations EventParticipation[] + familyParticipations FamilyEventParticipation[] @@index([kitaId, startDate]) @@index([kitaId, status]) @@ -806,6 +822,25 @@ model EventParticipation { @@map("event_participations") } +model FamilyEventParticipation { + 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) + familyId String + family Family @relation(fields: [familyId], references: [id], onDelete: Cascade) + status EventParticipationStatus + adultCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([eventId, familyId]) + @@index([kitaId]) + @@index([familyId]) + @@map("family_event_participations") +} + model MitbringselItem { id String @id @default(cuid()) kitaId String diff --git a/prisma/updates/20261007_parent_flows.sql b/prisma/updates/20261007_parent_flows.sql new file mode 100644 index 0000000..8ce3dda --- /dev/null +++ b/prisma/updates/20261007_parent_flows.sql @@ -0,0 +1,44 @@ +-- CreateEnum +CREATE TYPE "RsvpMode" AS ENUM ('CHILD', 'FAMILY'); + +-- CreateEnum +CREATE TYPE "AnnouncementAudience" AS ENUM ('ELTERN', 'ERZIEHER', 'ALLE'); + +-- AlterTable +ALTER TABLE "announcements" ADD COLUMN "audience" "AnnouncementAudience" NOT NULL DEFAULT 'ALLE'; + +-- AlterTable +ALTER TABLE "termine" ADD COLUMN "rsvpMode" "RsvpMode" NOT NULL DEFAULT 'CHILD'; + +-- CreateTable +CREATE TABLE "family_event_participations" ( + "id" TEXT NOT NULL, + "kitaId" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "familyId" TEXT NOT NULL, + "status" "EventParticipationStatus" NOT NULL, + "adultCount" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "family_event_participations_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "family_event_participations_kitaId_idx" ON "family_event_participations"("kitaId"); + +-- CreateIndex +CREATE INDEX "family_event_participations_familyId_idx" ON "family_event_participations"("familyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "family_event_participations_eventId_familyId_key" ON "family_event_participations"("eventId", "familyId"); + +-- AddForeignKey +ALTER TABLE "family_event_participations" ADD CONSTRAINT "family_event_participations_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "family_event_participations" ADD CONSTRAINT "family_event_participations_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "termine"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "family_event_participations" ADD CONSTRAINT "family_event_participations_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/src/actions/announcements.ts b/src/actions/announcements.ts index d781ef8..6f935cd 100644 --- a/src/actions/announcements.ts +++ b/src/actions/announcements.ts @@ -5,13 +5,17 @@ import { mkdir, rm, writeFile } from "fs/promises"; import path from "path"; import { createElement } from "react"; import { revalidatePath } from "next/cache"; -import { Prisma, UserRole } from "@prisma/client"; +import { AnnouncementAudience, UserRole } from "@prisma/client"; import { z } from "zod"; import { NewsEmail } from "@/emails/NewsEmail"; import { requireKitaSession, requireRole } from "@/lib/auth-utils"; import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail"; import { prisma } from "@/lib/prisma"; +import { + announcementRecipientWhere, + visibleAnnouncementAudiences, +} from "@/lib/announcement-access"; const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; const ALLOWED_TYPES = new Map([ @@ -55,25 +59,15 @@ function validateAttachment(file: File) { return null; } -async function getRecipients(kitaId: string, audience: z.infer) { - const where: Prisma.UserWhereInput = - audience === "ELTERN" - ? { - kitaId, - familyId: { not: null }, - emailVerifiedAt: { not: null }, - } - : audience === "ERZIEHER" - ? { - kitaId, - role: UserRole.ERZIEHER, - emailVerifiedAt: { not: null }, - } - : { - kitaId, - emailVerifiedAt: { not: null }, - OR: [{ familyId: { not: null } }, { role: UserRole.ERZIEHER }], - }; +async function getRecipients( + kitaId: string, + audience: z.infer, +) { + const where = { + kitaId, + emailVerifiedAt: { not: null }, + ...announcementRecipientWhere(audience as AnnouncementAudience), + }; const users = await prisma.user.findMany({ where, @@ -129,6 +123,7 @@ export async function createAnnouncement(formData: FormData) { kitaId, title: parsed.data.title, content: parsed.data.content, + audience: parsed.data.audience, authorId: session.user.id, attachments: { create: attachmentInputs.map((attachment) => ({ @@ -166,7 +161,10 @@ export async function createAnnouncement(formData: FormData) { rm(path.join(uploadDir, attachment.id), { force: true }), ), ); - console.error("Ankündigungs-Anhang konnte nicht gespeichert werden:", error); + console.error( + "Ankündigungs-Anhang konnte nicht gespeichert werden:", + error, + ); return { error: "Anhang konnte nicht gespeichert werden." }; } } @@ -198,6 +196,7 @@ export async function createAnnouncement(formData: FormData) { revalidatePath("/dashboard"); revalidatePath("/dashboard/admin/news"); + revalidatePath("/dashboard/mitteilungen"); if (emailError) { return { @@ -216,6 +215,7 @@ export async function markAnnouncementRead(announcementId: string) { where: { id: announcementId, kitaId: session.user.kitaId, + audience: { in: visibleAnnouncementAudiences(session.user) }, }, select: { id: true }, }); @@ -241,5 +241,6 @@ export async function markAnnouncementRead(announcementId: string) { }); revalidatePath("/dashboard"); + revalidatePath("/dashboard/mitteilungen"); return { success: true }; } diff --git a/src/actions/rsvp.ts b/src/actions/rsvp.ts index 83eede9..47162b7 100644 --- a/src/actions/rsvp.ts +++ b/src/actions/rsvp.ts @@ -3,20 +3,16 @@ import { revalidatePath } from "next/cache"; import { EventParticipationStatus, + RsvpMode, TerminStatus, UserRole, } from "@prisma/client"; -import { z } from "zod"; +import { rsvpSubmissionSchema } from "@/lib/rsvp"; +import { lockEvent } from "@/lib/event-lock"; 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; @@ -24,79 +20,120 @@ export type EventParticipantDto = { status: EventParticipationStatus | null; }; -export async function submitEventRsvp(rawPayload: unknown) { +export async function submitEventRsvp( + rawPayload: unknown, +): Promise<{ error?: string; success?: boolean }> { const session = await requireKitaSession(); - const parsed = submitRsvpSchema.safeParse(rawPayload); - - if (!parsed.success) { - return { error: "Ungültige Eingabedaten." }; + const parsed = rsvpSubmissionSchema.safeParse(rawPayload); + if (!parsed.success) + return { + error: + "Bitte alle Antworten und bei Familienzusagen die Anzahl Erwachsener angeben.", + }; + if (!session.user.familyId || session.user.role === UserRole.ERZIEHER) { + return { + error: "Für Rückmeldungen ist ein Elternkonto mit Haushalt erforderlich.", + }; } - - if (!session.user.familyId) { - return { error: "Dein Account ist noch keinem Haushalt zugeordnet." }; + const kitaId = session.user.kitaId; + const familyId = session.user.familyId; + const payload = parsed.data; + try { + const result = await prisma.$transaction(async (tx) => { + await lockEvent(tx, payload.eventId, kitaId); + const event = await tx.termin.findFirst({ + where: { id: payload.eventId, kitaId }, + select: { + status: true, + requiresRsvp: true, + rsvpMode: true, + rsvpDeadline: true, + startDate: true, + kita: { select: { terminModuleEnabled: true } }, + }, + }); + if ( + !event?.kita.terminModuleEnabled || + !event.requiresRsvp || + event.status !== TerminStatus.CONFIRMED + ) { + return { error: "Für diesen Termin ist keine Rückmeldung aktiv." }; + } + if (event.rsvpMode !== payload.mode) + return { + error: "Die Antwortart wurde geändert. Bitte lade den Termin erneut.", + }; + if (payload.mode === RsvpMode.FAMILY) { + const family = await tx.family.findFirst({ + where: { id: familyId, kitaId }, + select: { id: true }, + }); + if (!family) return { error: "Haushalt wurde nicht gefunden." }; + if ( + (event.rsvpDeadline ?? event.startDate) <= new Date() || + event.startDate <= new Date() + ) { + return { error: "Die Rückmeldefrist ist bereits abgelaufen." }; + } + await tx.familyEventParticipation.upsert({ + where: { eventId_familyId: { eventId: payload.eventId, familyId } }, + create: { + kitaId, + eventId: payload.eventId, + familyId, + status: payload.status, + adultCount: payload.adultCount, + }, + update: { status: payload.status, adultCount: payload.adultCount }, + }); + } else { + const children = await tx.child.findMany({ + where: { kitaId, familyId, active: true }, + select: { id: true }, + }); + const ids = new Set(children.map((child) => child.id)); + if ( + payload.responses.length !== ids.size || + payload.responses.some((row) => !ids.has(row.childId)) + ) { + return { + error: + "Bitte antworte für alle eigenen aktiven Kinder. Lade den Termin bei Änderungen erneut.", + }; + } + if ( + (event.rsvpDeadline ?? event.startDate) <= new Date() || + event.startDate <= new Date() + ) { + return { error: "Die Rückmeldefrist ist bereits abgelaufen." }; + } + for (const response of payload.responses) { + await tx.eventParticipation.upsert({ + where: { + eventId_childId: { + eventId: payload.eventId, + childId: response.childId, + }, + }, + create: { kitaId, eventId: payload.eventId, ...response }, + update: { status: response.status }, + }); + } + } + return { success: true }; + }); + if (result.error) return result; + revalidatePath("/dashboard"); + revalidatePath("/dashboard/kalender"); + revalidatePath("/dashboard/admin/kalender"); + return result; + } catch (error) { + console.error("Rückmeldung konnte nicht gespeichert werden:", error); + return { + error: + "Rückmeldung konnte nicht gespeichert werden. Bitte versuche es erneut.", + }; } - - 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( diff --git a/src/app/api/announcements/attachments/[id]/route.ts b/src/app/api/announcements/attachments/[id]/route.ts index 5ed780f..a04f2e5 100644 --- a/src/app/api/announcements/attachments/[id]/route.ts +++ b/src/app/api/announcements/attachments/[id]/route.ts @@ -4,6 +4,7 @@ import { NextResponse } from "next/server"; import { auth } from "@/auth"; import { prisma } from "@/lib/prisma"; +import { visibleAnnouncementAudiences } from "@/lib/announcement-access"; export const runtime = "nodejs"; @@ -37,6 +38,7 @@ export async function GET( id, announcement: { kitaId: user.kitaId, + audience: { in: visibleAnnouncementAudiences(user) }, }, }, select: { @@ -56,7 +58,7 @@ export async function GET( headers: { "Content-Type": attachment.fileType, "Content-Disposition": contentDisposition(attachment.fileName), - "Cache-Control": "private, max-age=300", + "Cache-Control": "private, no-store", }, }); } catch (error) { diff --git a/src/app/dashboard/admin/kalender/admin-calendar-manager.tsx b/src/app/dashboard/admin/kalender/admin-calendar-manager.tsx index c554794..59ce6f7 100644 --- a/src/app/dashboard/admin/kalender/admin-calendar-manager.tsx +++ b/src/app/dashboard/admin/kalender/admin-calendar-manager.tsx @@ -3,6 +3,7 @@ import { useMemo, useState, useTransition } from "react"; import { EventParticipationStatus, + RsvpMode, TerminStatus, TerminType, } from "@prisma/client"; @@ -19,6 +20,7 @@ import { } from "lucide-react"; import { toast } from "sonner"; +import { suggestedRsvpMode } from "@/lib/rsvp"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -48,10 +50,7 @@ import { TableRow, } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; -import { - createTerminAdmin, - updateTerminAdmin, -} from "../../kalender/actions"; +import { createTerminAdmin, updateTerminAdmin } from "../../kalender/actions"; type AdminTerminDto = { id: string; @@ -64,6 +63,12 @@ type AdminTerminDto = { allDay: boolean; mitbringselListEnabled: boolean; requiresRsvp: boolean; + rsvpMode: RsvpMode; + familyParticipations: { + familyId: string; + status: EventParticipationStatus; + adultCount: number; + }[]; rsvpDeadline: string | null; participantInfo: string | null; participations: { @@ -81,9 +86,11 @@ type ChildDto = { export function AdminCalendarManager({ termine, childRows, + familyRows, }: { termine: AdminTerminDto[]; childRows: ChildDto[]; + familyRows: { id: string; name: string }[]; }) { const rsvpCount = termine.filter((termin) => termin.requiresRsvp).length; const pendingCount = termine.filter( @@ -104,8 +111,8 @@ export function AdminCalendarManager({

- Termine verwalten, RSVP aktivieren und den Rücklauf pro Kind - verfolgen. + Termine verwalten, RSVP aktivieren und den Rücklauf pro Familie oder + Kind verfolgen.

@@ -134,7 +141,7 @@ export function AdminCalendarManager({ Noch keine Termine

- Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln. + Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.

@@ -145,6 +152,7 @@ export function AdminCalendarManager({ key={termin.id} termin={termin} childRows={childRows} + familyRows={familyRows} /> ))} @@ -156,11 +164,25 @@ export function AdminCalendarManager({ function AdminTerminCard({ termin, childRows, + familyRows, }: { termin: AdminTerminDto; childRows: ChildDto[]; + familyRows: { id: string; name: string }[]; }) { - const stats = useMemo(() => getStats(termin, childRows), [termin, childRows]); + const isFamily = termin.rsvpMode === RsvpMode.FAMILY; + const rows = useMemo( + () => + isFamily + ? familyRows.map((family) => ({ + id: family.id, + name: family.name, + familyName: family.name, + })) + : childRows, + [isFamily, familyRows, childRows], + ); + const stats = useMemo(() => getStats(termin, rows), [termin, rows]); return ( @@ -175,7 +197,9 @@ function AdminTerminCard({ {getStatusLabel(termin.status)} {termin.requiresRsvp && ( - RSVP aktiv + + Antwort pro {isFamily ? "Familie" : "Kind"} + )} {termin.title} @@ -217,14 +241,19 @@ function AdminTerminCard({

{stats.attending} Zugesagt / {stats.notAttending} Abgesagt /{" "} - {stats.pending} Ausstehend + {stats.pending} Ausstehend{" "} + {isFamily ? `· ${stats.adultCount} Erwachsene` : ""}

Frist:{" "} {termin.rsvpDeadline - ? format(new Date(termin.rsvpDeadline), "dd.MM.yyyy HH:mm", { - locale: de, - }) + ? format( + new Date(termin.rsvpDeadline), + "dd.MM.yyyy HH:mm", + { + locale: de, + }, + ) : "bis Terminbeginn"}

@@ -241,18 +270,24 @@ function AdminTerminCard({ - Kind - Familie + {isFamily ? "Familie" : "Kind"} + {isFamily ? "Erwachsene" : "Familie"} Status - {childRows.map((child) => { + {rows.map((child) => { const status = stats.statusByChildId.get(child.id) ?? null; return ( - {child.name} - {child.familyName} + + {child.name} + + + {isFamily + ? (stats.adultCounts.get(child.id) ?? "–") + : child.familyName} + @@ -283,6 +318,13 @@ function TerminFormDialog({ const [requiresRsvp, setRequiresRsvp] = useState( termin?.requiresRsvp ?? false, ); + const [rsvpMode, setRsvpMode] = useState( + termin?.rsvpMode ?? suggestedRsvpMode(TerminType.KITA_FEST), + ); + const [modeTouched, setModeTouched] = useState(false); + const modeLocked = + !!termin && + termin.participations.length + termin.familyParticipations.length > 0; const [isPending, startTransition] = useTransition(); function handleAction(formData: FormData) { @@ -300,6 +342,7 @@ function TerminFormDialog({ allDay: formData.get("allDay") === "on", mitbringselListEnabled: formData.get("mitbringselListEnabled") === "on", requiresRsvp: hasRsvp, + rsvpMode, rsvpDeadline: hasRsvp && deadlineRaw ? new Date(deadlineRaw).toISOString() : null, participantInfo: String(formData.get("participantInfo") ?? ""), @@ -330,6 +373,10 @@ function TerminFormDialog({ setOpen(nextOpen); if (nextOpen) { setRequiresRsvp(termin?.requiresRsvp ?? false); + setRsvpMode( + termin?.rsvpMode ?? suggestedRsvpMode(TerminType.KITA_FEST), + ); + setModeTouched(false); } }} > @@ -380,6 +427,12 @@ function TerminFormDialog({ { + setRsvpMode(e.target.value as RsvpMode); + setModeTouched(true); + }} + className="h-10 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 text-sm disabled:opacity-60" + > + + + + {modeLocked ? ( +

+ Die Antwortart ist gesperrt, da bereits Rückmeldungen + vorliegen. +

+ ) : null} + +
+ [ - participation.childId, - participation.status, - ]), +function getStats(termin: AdminTerminDto, rows: ChildDto[]) { + const isFamily = termin.rsvpMode === RsvpMode.FAMILY; + const statusByChildId = new Map( + isFamily + ? termin.familyParticipations.map((row) => [row.familyId, row.status]) + : termin.participations.map((row) => [row.childId, row.status]), + ); + const adultCounts = new Map( + termin.familyParticipations.map((row) => [row.familyId, row.adultCount]), ); - let attending = 0; let notAttending = 0; - - children.forEach((child) => { - const status = statusByChildId.get(child.id); - if (status === EventParticipationStatus.ATTENDING) attending += 1; + let adultCount = 0; + for (const row of rows) { + const status = statusByChildId.get(row.id); + if (status === EventParticipationStatus.ATTENDING) { + attending += 1; + adultCount += adultCounts.get(row.id) ?? 0; + } if (status === EventParticipationStatus.NOT_ATTENDING) notAttending += 1; - }); - + } return { attending, notAttending, - pending: Math.max(children.length - attending - notAttending, 0), + pending: rows.length - attending - notAttending, statusByChildId, + adultCounts, + adultCount, }; } diff --git a/src/app/dashboard/admin/kalender/page.tsx b/src/app/dashboard/admin/kalender/page.tsx index 9fcc6bb..768d808 100644 --- a/src/app/dashboard/admin/kalender/page.tsx +++ b/src/app/dashboard/admin/kalender/page.tsx @@ -14,7 +14,7 @@ export default async function AdminKalenderPage() { return
Kein Mandant zugeordnet.
; } - const [termine, children] = await Promise.all([ + const [termine, children, families] = await Promise.all([ prisma.termin.findMany({ where: { kitaId }, select: { @@ -28,6 +28,10 @@ export default async function AdminKalenderPage() { allDay: true, mitbringselListEnabled: true, requiresRsvp: true, + rsvpMode: true, + familyParticipations: { + select: { familyId: true, status: true, adultCount: true }, + }, rsvpDeadline: true, participantInfo: true, participations: { @@ -60,6 +64,11 @@ export default async function AdminKalenderPage() { { firstName: "asc" }, ], }), + prisma.family.findMany({ + where: { kitaId }, + select: { id: true, name: true }, + orderBy: { name: "asc" }, + }), ]); return ( @@ -70,6 +79,7 @@ export default async function AdminKalenderPage() { endDate: termin.endDate.toISOString(), rsvpDeadline: termin.rsvpDeadline?.toISOString() ?? null, }))} + familyRows={families} childRows={children.map((child) => ({ id: child.id, name: `${child.firstName} ${child.lastName}`, diff --git a/src/app/dashboard/admin/news/news-form.tsx b/src/app/dashboard/admin/news/news-form.tsx index b822620..7a577f8 100644 --- a/src/app/dashboard/admin/news/news-form.tsx +++ b/src/app/dashboard/admin/news/news-form.tsx @@ -64,7 +64,9 @@ export function NewsForm() { rows={10} value={content} onChange={(event) => setContent(event.target.value)} - placeholder={"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."} + placeholder={ + "## Liebe Familien,\n\nhier stehen die offiziellen Informationen..." + } required />

@@ -110,21 +112,23 @@ export function NewsForm() {

- {sendEmail && ( -
- - -
- )} +
+ +

+ Bestimmt, wer die Meldung und Anhänge in der App sehen darf und + optional die E-Mail erhält. +

+ +
diff --git a/src/app/dashboard/admin/news/page.tsx b/src/app/dashboard/admin/news/page.tsx index 0fde6ef..f885001 100644 --- a/src/app/dashboard/admin/news/page.tsx +++ b/src/app/dashboard/admin/news/page.tsx @@ -13,6 +13,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { announcementAudienceLabels } from "@/lib/announcement-access"; import { NewsForm } from "./news-form"; export const metadata = { title: "Schwarzes Brett · Kita-Planer" }; @@ -30,6 +31,7 @@ export default async function AdminNewsPage() { select: { id: true, title: true, + audience: true, createdAt: true, attachments: { select: { @@ -63,8 +65,8 @@ export default async function AdminNewsPage() {

Veröffentliche verbindliche Ankündigungen für deine Kita. Anhänge - werden geschützt gespeichert und nur für eingeloggte Nutzer - derselben Kita ausgeliefert. + werden geschützt gespeichert und nur an die gewählte Empfängergruppe + deiner Kita ausgeliefert.

@@ -127,6 +129,9 @@ export default async function AdminNewsPage() { )} + + {announcementAudienceLabels[announcement.audience]} +

{format(announcement.createdAt, "dd.MM.yyyy HH:mm", { locale: de, diff --git a/src/app/dashboard/kalender/_components/admin-termin-modal.tsx b/src/app/dashboard/kalender/_components/admin-termin-modal.tsx index e42c34f..d40688c 100644 --- a/src/app/dashboard/kalender/_components/admin-termin-modal.tsx +++ b/src/app/dashboard/kalender/_components/admin-termin-modal.tsx @@ -2,10 +2,11 @@ import type { ComponentProps, FormEvent } from "react"; import { useEffect, useMemo, useState, useTransition } from "react"; -import { TerminType } from "@prisma/client"; +import { RsvpMode, TerminType } from "@prisma/client"; import { Plus } from "lucide-react"; import { toast } from "sonner"; +import { suggestedRsvpMode } from "@/lib/rsvp"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -28,6 +29,11 @@ type AdminTerminFormState = { startDate: string; endDate: string; allDay: boolean; + requiresRsvp: boolean; + rsvpMode: RsvpMode; + rsvpDeadline: string; + participantInfo: string; + mitbringselListEnabled: boolean; }; const initialFormState: AdminTerminFormState = { @@ -37,6 +43,11 @@ const initialFormState: AdminTerminFormState = { startDate: "", endDate: "", allDay: false, + requiresRsvp: false, + rsvpMode: RsvpMode.FAMILY, + rsvpDeadline: "", + participantInfo: "", + mitbringselListEnabled: false, }; type AdminTerminModalProps = { @@ -51,6 +62,12 @@ type AdminTerminModalProps = { startDate: Date; endDate: Date; allDay: boolean; + requiresRsvp: boolean; + rsvpMode: RsvpMode; + rsvpDeadline: Date | null; + participantInfo: string | null; + mitbringselListEnabled: boolean; + hasRsvpResponses: boolean; }; }; @@ -61,10 +78,12 @@ export function AdminTerminModal({ existingTermin, }: AdminTerminModalProps) { const [open, setOpen] = useState(false); - const [formState, setFormState] = useState(initialFormState); + const [formState, setFormState] = + useState(initialFormState); + const [modeTouched, setModeTouched] = useState(false); + const modeLocked = existingTermin?.hasRsvpResponses ?? false; const [isPending, startTransition] = useTransition(); const minDateTime = useMemo(() => getLocalDateTimeInputValue(new Date()), []); - const minEndDateTime = formState.startDate || minDateTime; // Initialize and reset form fields when modal opens useEffect(() => { @@ -78,10 +97,18 @@ export function AdminTerminModal({ startDate: getLocalDateTimeInputValue(existingTermin.startDate), endDate: getLocalDateTimeInputValue(existingTermin.endDate), allDay: existingTermin.allDay, + requiresRsvp: existingTermin.requiresRsvp, + rsvpMode: existingTermin.rsvpMode, + rsvpDeadline: existingTermin.rsvpDeadline + ? getLocalDateTimeInputValue(existingTermin.rsvpDeadline) + : "", + participantInfo: existingTermin.participantInfo ?? "", + mitbringselListEnabled: existingTermin.mitbringselListEnabled, }); } else { setFormState(initialFormState); } + setModeTouched(false); } }, [open, existingTermin]); @@ -91,7 +118,14 @@ export function AdminTerminModal({ ) => { setFormState((current) => { const next = { ...current, [key]: value }; - if (key === "startDate" && typeof value === "string" && next.endDate && next.endDate < value) { + if (key === "type" && !existingTermin && !modeTouched && !modeLocked) + next.rsvpMode = suggestedRsvpMode(value as TerminType); + if ( + key === "startDate" && + typeof value === "string" && + next.endDate && + next.endDate < value + ) { next.endDate = value; } return next; @@ -122,6 +156,13 @@ export function AdminTerminModal({ startDate: new Date(formState.startDate).toISOString(), endDate: new Date(formState.endDate).toISOString(), allDay: formState.allDay, + requiresRsvp: formState.requiresRsvp, + rsvpMode: formState.rsvpMode, + rsvpDeadline: formState.rsvpDeadline + ? new Date(formState.rsvpDeadline).toISOString() + : null, + participantInfo: formState.participantInfo, + mitbringselListEnabled: formState.mitbringselListEnabled, }; startTransition(async () => { @@ -138,7 +179,7 @@ export function AdminTerminModal({ toast.success( existingTermin ? "Termin wurde erfolgreich aktualisiert!" - : "Termin wurde direkt angelegt!" + : "Termin wurde direkt angelegt!", ); if (!existingTermin) { setFormState(initialFormState); @@ -156,11 +197,13 @@ export function AdminTerminModal({ {triggerLabel} - +
- {existingTermin ? "Termin bearbeiten (Admin)" : "Termin anlegen (Admin)"} + {existingTermin + ? "Termin bearbeiten (Admin)" + : "Termin anlegen (Admin)"} {existingTermin @@ -189,7 +232,9 @@ export function AdminTerminModal({ name="description" placeholder="Details zum Termin..." value={formState.description} - onChange={(event) => updateField("description", event.target.value)} + onChange={(event) => + updateField("description", event.target.value) + } /> @@ -200,18 +245,28 @@ export function AdminTerminModal({ name="type" value={formState.type} className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]" - onChange={(event) => updateField("type", event.target.value as TerminType)} + onChange={(event) => + updateField("type", event.target.value as TerminType) + } required > - + - + - - + + @@ -225,7 +280,9 @@ export function AdminTerminModal({ type="datetime-local" min={existingTermin ? undefined : minDateTime} value={formState.startDate} - onChange={(event) => updateField("startDate", event.target.value)} + onChange={(event) => + updateField("startDate", event.target.value) + } required /> @@ -235,14 +292,82 @@ export function AdminTerminModal({ id="endDate" name="endDate" type="datetime-local" - min={formState.startDate || (existingTermin ? undefined : minDateTime)} + min={ + formState.startDate || + (existingTermin ? undefined : minDateTime) + } value={formState.endDate} - onChange={(event) => updateField("endDate", event.target.value)} + onChange={(event) => + updateField("endDate", event.target.value) + } required /> + + + {formState.requiresRsvp ? ( +

+ + + {modeLocked ? ( +

+ Die Antwortart ist wegen vorhandener Rückmeldungen gesperrt. +

+ ) : null} + + updateField("rsvpDeadline", e.target.value)} + /> + +