feat: add parent announcements and family or child event responses

This commit is contained in:
t.indorf
2026-10-09 21:44:19 +02:00
parent 2d3b82eb80
commit 979f795341
29 changed files with 2031 additions and 472 deletions
+39
View File
@@ -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.
+3 -1
View File
@@ -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"
+39 -4
View File
@@ -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
+44
View File
@@ -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;
+22 -21
View File
@@ -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<typeof audienceSchema>) {
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<typeof audienceSchema>,
) {
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 };
}
+114 -77
View File
@@ -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(
@@ -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) {
@@ -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({
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Termine verwalten, RSVP aktivieren und den Rücklauf pro Kind
verfolgen.
Termine verwalten, RSVP aktivieren und den Rücklauf pro Familie oder
Kind verfolgen.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
@@ -134,7 +141,7 @@ export function AdminCalendarManager({
Noch keine Termine
</h2>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
</p>
</div>
</Card>
@@ -145,6 +152,7 @@ export function AdminCalendarManager({
key={termin.id}
termin={termin}
childRows={childRows}
familyRows={familyRows}
/>
))}
</div>
@@ -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 (
<Card className="overflow-hidden">
@@ -175,7 +197,9 @@ function AdminTerminCard({
{getStatusLabel(termin.status)}
</Badge>
{termin.requiresRsvp && (
<Badge variant="warning">RSVP aktiv</Badge>
<Badge variant="warning">
Antwort pro {isFamily ? "Familie" : "Kind"}
</Badge>
)}
</div>
<CardTitle className="text-lg">{termin.title}</CardTitle>
@@ -217,14 +241,19 @@ function AdminTerminCard({
<div>
<p className="font-medium">
{stats.attending} Zugesagt / {stats.notAttending} Abgesagt /{" "}
{stats.pending} Ausstehend
{stats.pending} Ausstehend{" "}
{isFamily ? `· ${stats.adultCount} Erwachsene` : ""}
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
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"}
</p>
</div>
@@ -241,18 +270,24 @@ function AdminTerminCard({
<Table>
<TableHeader>
<TableRow>
<TableHead>Kind</TableHead>
<TableHead>Familie</TableHead>
<TableHead>{isFamily ? "Familie" : "Kind"}</TableHead>
<TableHead>{isFamily ? "Erwachsene" : "Familie"}</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{childRows.map((child) => {
{rows.map((child) => {
const status = stats.statusByChildId.get(child.id) ?? null;
return (
<TableRow key={child.id}>
<TableCell className="font-medium">{child.name}</TableCell>
<TableCell>{child.familyName}</TableCell>
<TableCell className="font-medium">
{child.name}
</TableCell>
<TableCell>
{isFamily
? (stats.adultCounts.get(child.id) ?? "–")
: child.familyName}
</TableCell>
<TableCell>
<RsvpStatusBadge status={status} />
</TableCell>
@@ -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({
<select
id={`${mode}-type`}
name="type"
onChange={(event) => {
if (!termin && !modeLocked && !modeTouched)
setRsvpMode(
suggestedRsvpMode(event.target.value as TerminType),
);
}}
defaultValue={termin?.type ?? TerminType.KITA_FEST}
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:bg-[oklch(0.99_0.005_75)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
required
@@ -453,9 +506,33 @@ function TerminFormDialog({
{requiresRsvp && (
<div className="grid gap-4 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4">
<div className="grid gap-2">
<Label htmlFor={`${mode}-rsvpDeadline`}>
Rückmeldefrist
</Label>
<Label htmlFor={`${mode}-rsvpMode`}>Antwortart</Label>
<select
id={`${mode}-rsvpMode`}
value={rsvpMode}
disabled={modeLocked}
onChange={(e) => {
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"
>
<option value="FAMILY">
Pro Familie · mit Anzahl Erwachsener
</option>
<option value="CHILD">
Pro Kind · einzelne Zu-/Absage
</option>
</select>
{modeLocked ? (
<p className="text-xs text-[var(--ink-soft)]">
Die Antwortart ist gesperrt, da bereits Rückmeldungen
vorliegen.
</p>
) : null}
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-rsvpDeadline`}>Rückmeldefrist</Label>
<Input
id={`${mode}-rsvpDeadline`}
name="rsvpDeadline"
@@ -532,28 +609,34 @@ function RsvpStatusBadge({
);
}
function getStats(termin: AdminTerminDto, children: ChildDto[]) {
const statusByChildId = new Map(
termin.participations.map((participation) => [
participation.childId,
participation.status,
]),
function getStats(termin: AdminTerminDto, rows: ChildDto[]) {
const isFamily = termin.rsvpMode === RsvpMode.FAMILY;
const statusByChildId = new Map<string, EventParticipationStatus>(
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,
};
}
+11 -1
View File
@@ -14,7 +14,7 @@ export default async function AdminKalenderPage() {
return <div className="p-8">Kein Mandant zugeordnet.</div>;
}
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}`,
+20 -16
View File
@@ -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
/>
<p className="text-xs text-[var(--ink-muted)]">
@@ -110,21 +112,23 @@ export function NewsForm() {
</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 className="mt-4 grid gap-2">
<Label htmlFor="news-audience">Empfängergruppe</Label>
<p className="text-xs text-[var(--ink-soft)]">
Bestimmt, wer die Meldung und Anhänge in der App sehen darf und
optional die E-Mail erhält.
</p>
<Select name="audience" defaultValue="ELTERN">
<SelectTrigger id="news-audience">
<SelectValue placeholder="Empfänger auswählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ELTERN">Eltern</SelectItem>
<SelectItem value="ERZIEHER">ErzieherInnen</SelectItem>
<SelectItem value="ALLE">Eltern und ErzieherInnen</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end">
+7 -2
View File
@@ -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() {
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
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.
</p>
</div>
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] tabular-nums">
@@ -127,6 +129,9 @@ export default async function AdminNewsPage() {
</Badge>
)}
</div>
<Badge variant="outline" className="mb-2">
{announcementAudienceLabels[announcement.audience]}
</Badge>
<p className="text-xs text-[var(--ink-muted)]">
{format(announcement.createdAt, "dd.MM.yyyy HH:mm", {
locale: de,
@@ -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<AdminTerminFormState>(initialFormState);
const [formState, setFormState] =
useState<AdminTerminFormState>(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}
</Button>
</DialogTrigger>
<DialogContent>
<DialogContent className="max-h-[85vh] overflow-y-auto">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>
{existingTermin ? "Termin bearbeiten (Admin)" : "Termin anlegen (Admin)"}
{existingTermin
? "Termin bearbeiten (Admin)"
: "Termin anlegen (Admin)"}
</DialogTitle>
<DialogDescription>
{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)
}
/>
</div>
@@ -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
>
<option value={TerminType.KITA_FEST}>Kita-Fest</option>
<option value={TerminType.SCHLIESSTAG}>Schließtag</option>
<option value={TerminType.TEAMTAG}>Teamtag (Kita geschlossen)</option>
<option value={TerminType.TEAMTAG}>
Teamtag (Kita geschlossen)
</option>
<option value={TerminType.MITMACH_TAG}>Mitmach-Tag</option>
<option value={TerminType.ELTERNABEND}>Elternabend</option>
<option value={TerminType.MITGLIEDERVERSAMMLUNG}>Mitgliederversammlung</option>
<option value={TerminType.MITGLIEDERVERSAMMLUNG}>
Mitgliederversammlung
</option>
<option value={TerminType.ELTERNCAFE}>Elterncafe</option>
<option value={TerminType.GEBURTSTAG_INTERN}>Geburtstag (Intern)</option>
<option value={TerminType.GEBURTSTAG_EXTERN}>Raumanfrage (Extern)</option>
<option value={TerminType.GEBURTSTAG_INTERN}>
Geburtstag (Intern)
</option>
<option value={TerminType.GEBURTSTAG_EXTERN}>
Raumanfrage (Extern)
</option>
<option value={TerminType.SONSTIGES}>Sonstiges</option>
</select>
</div>
@@ -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
/>
</div>
@@ -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
/>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={formState.mitbringselListEnabled}
onChange={(e) =>
updateField("mitbringselListEnabled", e.target.checked)
}
/>
Mitbring-Liste aktiv
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={formState.requiresRsvp}
onChange={(e) => updateField("requiresRsvp", e.target.checked)}
/>
Teilnahme-Abfrage aktivieren
</label>
{formState.requiresRsvp ? (
<div className="grid gap-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4">
<Label htmlFor="calendar-rsvp-mode">Antwortart</Label>
<select
id="calendar-rsvp-mode"
value={formState.rsvpMode}
disabled={modeLocked}
onChange={(e) => {
updateField("rsvpMode", e.target.value as RsvpMode);
setModeTouched(true);
}}
className="h-10 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 text-sm"
>
<option value="FAMILY">
Pro Familie · mit Anzahl Erwachsener
</option>
<option value="CHILD">Pro Kind · einzelne Zu-/Absage</option>
</select>
{modeLocked ? (
<p className="text-xs">
Die Antwortart ist wegen vorhandener Rückmeldungen gesperrt.
</p>
) : null}
<Label htmlFor="calendar-rsvp-deadline">
Rückmeldefrist (sonst Terminbeginn)
</Label>
<Input
id="calendar-rsvp-deadline"
type="datetime-local"
value={formState.rsvpDeadline}
onChange={(e) => updateField("rsvpDeadline", e.target.value)}
/>
<Label htmlFor="calendar-participant-info">
Hinweise für Teilnehmer
</Label>
<Textarea
id="calendar-participant-info"
value={formState.participantInfo}
onChange={(e) =>
updateField("participantInfo", e.target.value)
}
/>
</div>
) : null}
<div className="flex items-center gap-2 mt-2">
<input
type="checkbox"
@@ -250,7 +375,9 @@ export function AdminTerminModal({
name="allDay"
checked={formState.allDay}
className="rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
onChange={(event) => updateField("allDay", event.target.checked)}
onChange={(event) =>
updateField("allDay", event.target.checked)
}
/>
<Label htmlFor="allDay" className="font-normal cursor-pointer">
Ganztägiger Termin
@@ -259,11 +386,19 @@ export function AdminTerminModal({
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Speichern..." : existingTermin ? "Änderungen speichern" : "Termin erstellen"}
{isPending
? "Speichern..."
: existingTermin
? "Änderungen speichern"
: "Termin erstellen"}
</Button>
</DialogFooter>
</form>
@@ -30,6 +30,7 @@ import {
WashingMachine,
Trash2,
} from "lucide-react";
import { RsvpDialog, RsvpForm } from "@/components/rsvp-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -522,6 +523,11 @@ export function CalendarOverviewClient({
</p>
)}
{item.rsvpEvent ? <section className="mt-4 border-t border-[var(--hairline)] pt-4">
<h5 className="mb-3 font-medium">Eure Rückmeldung</h5>
<RsvpForm key={JSON.stringify(item.rsvpEvent)} event={item.rsvpEvent} />
</section> : null}
{item.participantInfo && item.showParticipantInfo && (
<div className="mt-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-3 text-xs text-[var(--ink)]">
<div className="mb-0.5 flex items-center gap-1.5 font-medium">
@@ -999,6 +1005,8 @@ function AgendaView({
</div>
</div>
{item.kind === "termin" && item.rsvpEvent ? <RsvpDialog event={item.rsvpEvent} /> : null}
{/* Actions for admins */}
{isAdmin && item.kind === "termin" && (
<div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
@@ -2,7 +2,12 @@
import type { ReactNode } from "react";
import { useState, useTransition } from "react";
import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client";
import {
DutyAssignmentStatus,
RsvpMode,
TerminStatus,
TerminType,
} from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import {
@@ -15,6 +20,8 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { RsvpForm } from "@/components/rsvp-form";
import type { RsvpEventDto } from "@/lib/rsvp";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -44,6 +51,10 @@ export type TerminListItemDto = {
allDay: boolean;
mitbringselListEnabled: boolean;
requiresRsvp: boolean;
rsvpMode: RsvpMode;
rsvpDeadline: Date | null;
hasRsvpResponses: boolean;
rsvpEvent?: RsvpEventDto;
participantInfo: string | null;
showParticipantInfo: boolean;
mitbringselItems?: {
@@ -88,7 +99,10 @@ export type NotdienstSwapOptionDto = {
familyName: string;
};
export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto | NotdienstCalendarItemDto;
export type CalendarListItemDto =
| TerminListItemDto
| DutyCalendarItemDto
| NotdienstCalendarItemDto;
export function TerminList({
termine,
@@ -116,7 +130,10 @@ export function TerminList({
</p>
<div className="mt-5">
{isAdmin ? (
<AdminTerminModal triggerLabel="+ Termin anlegen" triggerClassName="" />
<AdminTerminModal
triggerLabel="+ Termin anlegen"
triggerClassName=""
/>
) : (
<TerminRequestModal
triggerLabel="+ Termin anlegen"
@@ -141,14 +158,19 @@ export function TerminList({
{group.label}
</h2>
<span className="rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-[var(--ink-soft)] tabular-nums">
{group.items.length} {group.items.length === 1 ? "Termin" : "Termine"}
{group.items.length}{" "}
{group.items.length === 1 ? "Termin" : "Termine"}
</span>
</div>
<Card className="overflow-hidden p-0">
<div className="divide-y divide-[var(--hairline-soft)]">
{group.items.map((item) =>
item.kind === "duty" ? (
<DutyRow key={`duty-${item.id}`} duty={item} isAdmin={isAdmin} />
<DutyRow
key={`duty-${item.id}`}
duty={item}
isAdmin={isAdmin}
/>
) : item.kind === "notdienst" ? (
<NotdienstRow
key={`notdienst-${item.id}`}
@@ -157,7 +179,12 @@ export function TerminList({
notdienstSwapOptions={notdienstSwapOptions}
/>
) : (
<TerminRow key={`termin-${item.id}`} termin={item} userId={userId} isAdmin={isAdmin} />
<TerminRow
key={`termin-${item.id}`}
termin={item}
userId={userId}
isAdmin={isAdmin}
/>
),
)}
</div>
@@ -168,13 +195,22 @@ export function TerminList({
);
}
function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolean }) {
function DutyRow({
duty,
isAdmin,
}: {
duty: DutyCalendarItemDto;
isAdmin: boolean;
}) {
const color = duty.isOwn ? "oklch(0.60 0.16 160)" : "oklch(0.75 0.08 160)";
const [menuOpen, setMenuOpen] = useState(false);
return (
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
<div
className="absolute inset-y-3 left-0 w-[3px] rounded-r-full"
style={{ backgroundColor: color }}
/>
<DateTile date={duty.startDate} />
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
@@ -207,7 +243,10 @@ function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolea
</Button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div
className="fixed inset-0 z-40"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<a
href="/dashboard/admin/dienste"
@@ -257,7 +296,10 @@ function TerminRow({
<>
<Dialog>
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
<div
className="absolute inset-y-3 left-0 w-[3px] rounded-r-full"
style={{ backgroundColor: color }}
/>
<DateTile date={termin.startDate} />
<div className="min-w-0">
<div className="flex items-center gap-2">
@@ -276,7 +318,9 @@ function TerminRow({
</p>
</div>
<div className="flex items-center gap-2 relative">
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
<CategoryPill color={color}>
{getTypeLabel(termin.type)}
</CategoryPill>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
Details
@@ -297,7 +341,10 @@ function TerminRow({
</Button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div
className="fixed inset-0 z-40"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 mt-1 w-48 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<AdminTerminModal
existingTermin={termin}
@@ -342,7 +389,9 @@ function TerminRow({
<div className="grid gap-4">
<div className="flex">
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
<CategoryPill color={color}>
{getTypeLabel(termin.type)}
</CategoryPill>
</div>
{termin.description ? (
@@ -355,6 +404,16 @@ function TerminRow({
</p>
)}
{termin.rsvpEvent ? (
<section className="rounded-lg border border-[var(--hairline)] p-4">
<h3 className="mb-3 font-medium">Eure Rückmeldung</h3>
<RsvpForm
key={JSON.stringify(termin.rsvpEvent)}
event={termin.rsvpEvent}
/>
</section>
) : null}
{termin.participantInfo && termin.showParticipantInfo && (
<div className="rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-sm text-[var(--ink)]">
<div className="mb-1 flex items-center gap-2 font-medium">
@@ -385,8 +444,9 @@ function TerminRow({
<DialogHeader>
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
<DialogDescription>
Möchtest du den Termin &ldquo;{termin.title}&rdquo; wirklich löschen? Alle
zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren.
Möchtest du den Termin &ldquo;{termin.title}&rdquo; wirklich
löschen? Alle zugehörigen Teilnahmen und Mitbringsel-Einträge
gehen verloren.
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -436,14 +496,20 @@ function CategoryPill({
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-[var(--ink-soft)]"
style={{ color }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: color }} />
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: color }}
/>
{children}
</span>
);
}
function groupByMonth(items: CalendarListItemDto[]) {
const groups = new Map<string, { key: string; label: string; items: CalendarListItemDto[] }>();
const groups = new Map<
string,
{ key: string; label: string; items: CalendarListItemDto[] }
>();
items.forEach((item) => {
const key = format(item.startDate, "yyyy-MM");
@@ -559,7 +625,8 @@ export function NotdienstSwapDialog({
<DialogTitle>Notdienst tauschen</DialogTitle>
<DialogDescription>
Frage Familie {target.familyName}, ob ihr den Notdienst am{" "}
{format(target.startDate, "dd.MM.yyyy", { locale: de })} tauschen könnt.
{format(target.startDate, "dd.MM.yyyy", { locale: de })} tauschen
könnt.
</DialogDescription>
</DialogHeader>
@@ -575,11 +642,14 @@ export function NotdienstSwapDialog({
onChange={(event) => setOfferedAssignmentId(event.target.value)}
>
{selectableOptions.length === 0 ? (
<option value="">Kein eigener zukünftiger Notdienst verfügbar</option>
<option value="">
Kein eigener zukünftiger Notdienst verfügbar
</option>
) : (
selectableOptions.map((option) => (
<option key={option.id} value={option.id}>
{format(option.date, "dd.MM.yyyy", { locale: de })} - {option.childName}
{format(option.date, "dd.MM.yyyy", { locale: de })} -{" "}
{option.childName}
</option>
))
)}
@@ -603,7 +673,11 @@ export function NotdienstSwapDialog({
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={isPending}>
<Button
variant="ghost"
onClick={() => setOpen(false)}
disabled={isPending}
>
Abbrechen
</Button>
<Button
@@ -632,7 +706,10 @@ function NotdienstRow({
return (
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
<div
className="absolute inset-y-3 left-0 w-[3px] rounded-r-full"
style={{ backgroundColor: color }}
/>
<DateTile date={notdienst.startDate} />
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
@@ -640,7 +717,8 @@ function NotdienstRow({
</h3>
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(notdienst.startDate, "dd.MM.yyyy", { locale: de })} (ganztägig)
{format(notdienst.startDate, "dd.MM.yyyy", { locale: de })}{" "}
(ganztägig)
<span className="inline-flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--ink-muted)]" />
Kind: {notdienst.childName}
@@ -666,7 +744,10 @@ function NotdienstRow({
</Button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div
className="fixed inset-0 z-40"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<a
href={`/dashboard/notdienst/plan?year=${notdienst.planYear}&month=${notdienst.planMonth}`}
@@ -684,7 +765,11 @@ function NotdienstRow({
target={notdienst}
options={notdienstSwapOptions}
trigger={
<Button size="sm" variant="outline" className="h-8 px-2.5 text-xs">
<Button
size="sm"
variant="outline"
className="h-8 px-2.5 text-xs"
>
Tausch anfragen
</Button>
}
+73 -18
View File
@@ -6,6 +6,7 @@ import {
NotdienstAlertStatus,
NotdienstPlanStatus,
NotdienstSwapStatus,
RsvpMode,
TerminStatus,
TerminType,
UserRole,
@@ -18,6 +19,7 @@ import { NotdienstSwapEmail } from "@/emails/NotdienstSwapEmail";
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma";
import { lockEvent } from "@/lib/event-lock";
// =====================================================================
// /dashboard/kalender · Server Actions
@@ -30,8 +32,9 @@ const terminSchema = z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
allDay: z.boolean().default(false),
mitbringselListEnabled: z.boolean().default(false),
requiresRsvp: z.boolean().default(false),
mitbringselListEnabled: z.boolean().optional(),
requiresRsvp: z.boolean().optional(),
rsvpMode: z.nativeEnum(RsvpMode).optional(),
rsvpDeadline: z.string().datetime().nullable().optional(),
participantInfo: z.string().trim().max(1000).optional(),
});
@@ -274,13 +277,16 @@ async function notifyFamilyAboutSwap({
);
}
function buildTerminData(data: z.infer<typeof terminSchema>) {
function buildTerminData(
data: z.infer<typeof terminSchema>,
allowPast = false,
) {
const startDate = new Date(data.startDate);
const endDate = new Date(data.endDate);
const now = new Date();
now.setSeconds(0, 0);
if (startDate < now) {
if (!allowPast && startDate < now) {
return {
error: "Der Startzeitpunkt darf nicht in der Vergangenheit liegen.",
};
@@ -290,6 +296,16 @@ function buildTerminData(data: z.infer<typeof terminSchema>) {
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
}
if (
data.requiresRsvp &&
data.rsvpDeadline &&
new Date(data.rsvpDeadline) > startDate
) {
return {
error: "Die Rückmeldefrist darf nicht nach dem Terminbeginn liegen.",
};
}
return {
data: {
title: data.title,
@@ -298,8 +314,9 @@ function buildTerminData(data: z.infer<typeof terminSchema>) {
startDate,
endDate,
allDay: data.allDay,
mitbringselListEnabled: data.mitbringselListEnabled,
requiresRsvp: data.requiresRsvp,
mitbringselListEnabled: data.mitbringselListEnabled ?? false,
requiresRsvp: data.requiresRsvp ?? false,
rsvpMode: data.rsvpMode ?? RsvpMode.CHILD,
rsvpDeadline:
data.requiresRsvp && data.rsvpDeadline
? new Date(data.rsvpDeadline)
@@ -852,6 +869,7 @@ export async function createTerminAdmin(rawPayload: unknown) {
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Erstellen des Termins:", error);
@@ -859,7 +877,10 @@ export async function createTerminAdmin(rawPayload: unknown) {
}
}
export async function updateTerminAdmin(terminId: string, rawPayload: unknown) {
export async function updateTerminAdmin(
terminId: string,
rawPayload: unknown,
): Promise<{ error?: string; success?: boolean }> {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
@@ -872,19 +893,50 @@ export async function updateTerminAdmin(terminId: string, rawPayload: unknown) {
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,
const result = await prisma.$transaction(async (tx) => {
await lockEvent(tx, terminId, kitaId);
const existing = await tx.termin.findFirst({
where: { id: terminId, kitaId },
include: {
_count: {
select: { participations: true, familyParticipations: true },
},
},
});
if (!existing) return { error: "Termin wurde nicht gefunden." };
const data = {
...parsed.data,
mitbringselListEnabled:
parsed.data.mitbringselListEnabled ?? existing.mitbringselListEnabled,
requiresRsvp: parsed.data.requiresRsvp ?? existing.requiresRsvp,
rsvpMode: parsed.data.rsvpMode ?? existing.rsvpMode,
rsvpDeadline:
parsed.data.rsvpDeadline === undefined
? (existing.rsvpDeadline?.toISOString() ?? null)
: parsed.data.rsvpDeadline,
participantInfo:
parsed.data.participantInfo ?? existing.participantInfo ?? "",
};
if (
data.rsvpMode !== existing.rsvpMode &&
existing._count.participations + existing._count.familyParticipations >
0
) {
return {
error:
"Die Antwortart kann nach der ersten Rückmeldung nicht mehr geändert werden.",
};
}
const terminData = buildTerminData(data, true);
if ("error" in terminData) return { error: terminData.error };
await tx.termin.update({
where: { id: terminId, kitaId },
data: terminData.data,
});
return { success: true };
});
if (result.error) return result;
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
@@ -918,6 +970,7 @@ export async function approveTermin(terminId: string) {
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Bestätigen des Termins:", error);
@@ -948,6 +1001,7 @@ export async function rejectTermin(terminId: string, reason?: string) {
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Ablehnen des Termins:", error);
@@ -978,6 +1032,7 @@ export async function toggleMitbringselList(
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Umschalten der Mitbring-Liste:", error);
+95 -66
View File
@@ -9,9 +9,16 @@ import { HelpCircle } from "lucide-react";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getRsvpEvents } from "@/lib/rsvp-events";
import { AdminTerminModal } from "./_components/admin-termin-modal";
import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-anfragen";
import { type CalendarListItemDto, type NotdienstSwapOptionDto } from "./_components/termin-list";
import {
PendingAnfragen,
type PendingTerminDto,
} from "./_components/pending-anfragen";
import {
type CalendarListItemDto,
type NotdienstSwapOptionDto,
} from "./_components/termin-list";
import { TerminRequestModal } from "./_components/termin-request-modal";
import {
CalendarOverviewClient,
@@ -36,50 +43,62 @@ export default async function KalenderPage({
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const confirmedTermineRows = await prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.CONFIRMED,
},
select: {
id: true,
title: true,
description: true,
type: true,
status: true,
startDate: true,
endDate: true,
allDay: true,
mitbringselListEnabled: true,
requiresRsvp: true,
participantInfo: true,
mitbringselItems: {
select: {
id: true,
userId: true,
content: true,
user: {
select: {
firstName: true,
lastName: true,
const [rsvpEventRows, confirmedTermineRows] = await Promise.all([
getRsvpEvents(session),
prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.CONFIRMED,
},
select: {
id: true,
title: true,
description: true,
type: true,
status: true,
startDate: true,
endDate: true,
allDay: true,
mitbringselListEnabled: true,
requiresRsvp: true,
rsvpMode: true,
rsvpDeadline: true,
_count: {
select: { participations: true, familyParticipations: true },
},
familyParticipations: {
where: { familyId: familyIdForParticipation },
select: { status: true },
},
participantInfo: true,
mitbringselItems: {
select: {
id: true,
userId: true,
content: true,
user: {
select: {
firstName: true,
lastName: true,
},
},
},
},
},
participations: {
where: {
child: {
familyId: familyIdForParticipation,
participations: {
where: {
child: {
familyId: familyIdForParticipation,
},
},
select: {
childId: true,
status: true,
},
},
select: {
childId: true,
status: true,
},
},
},
orderBy: { startDate: "asc" },
});
orderBy: { startDate: "asc" },
}),
]);
const myPendingTermineRows = await prisma.termin.findMany({
where: {
@@ -98,6 +117,13 @@ export default async function KalenderPage({
allDay: true,
mitbringselListEnabled: true,
requiresRsvp: true,
rsvpMode: true,
rsvpDeadline: true,
_count: { select: { participations: true, familyParticipations: true } },
familyParticipations: {
where: { familyId: familyIdForParticipation },
select: { status: true },
},
participantInfo: true,
mitbringselItems: {
select: {
@@ -236,27 +262,32 @@ export default async function KalenderPage({
})
: [];
const rsvpEvents = new Map(rsvpEventRows.map((event) => [event.id, event]));
const mapTermin = (
termin: (typeof confirmedTermineRows)[number],
): CalendarListItemDto => {
const showParticipantInfo =
isAdmin ||
termin.familyParticipations.some(
(row) => row.status === EventParticipationStatus.ATTENDING,
) ||
termin.participations.some(
(row) => row.status === EventParticipationStatus.ATTENDING,
);
const { _count, familyParticipations, ...data } = termin;
void familyParticipations;
return {
...data,
kind: "termin",
showParticipantInfo,
participantInfo: showParticipantInfo ? termin.participantInfo : null,
hasRsvpResponses: _count.participations + _count.familyParticipations > 0,
rsvpEvent: rsvpEvents.get(termin.id),
};
};
const allUserTermine: CalendarListItemDto[] = [
...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,
),
})),
...confirmedTermineRows.map(mapTermin),
...myPendingTermineRows.map(mapTermin),
...dutyAssignmentRows.map((assignment) => {
const isOwn = assignment.familyId === familyIdForParticipation;
return {
@@ -316,7 +347,9 @@ export default async function KalenderPage({
(request) => ({
id: request.id,
direction:
request.requesterFamilyId === session.user.familyId ? "outgoing" : "incoming",
request.requesterFamilyId === session.user.familyId
? "outgoing"
: "incoming",
targetDate: request.targetAssignment.date,
targetFamilyName: request.targetAssignment.child.family.name,
targetChildName: `${request.targetAssignment.child.firstName} ${request.targetAssignment.child.lastName}`,
@@ -367,9 +400,7 @@ export default async function KalenderPage({
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Was steht{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
an
</em>
<em className="font-normal italic text-[var(--accent-deep)]">an</em>
?
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
@@ -433,8 +464,6 @@ export default async function KalenderPage({
);
}
function TabBar({
currentTab,
overviewCount,
+67
View File
@@ -0,0 +1,67 @@
import Link from "next/link";
import { requireKitaSession } from "@/lib/auth-utils";
import { getAnnouncements } from "@/lib/announcements";
import { NewsTicker } from "../news-ticker";
import { Button } from "@/components/ui/button";
export const metadata = { title: "Mitteilungen · Kita-Planer" };
export default async function MitteilungenPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const session = await requireKitaSession();
const params = await searchParams;
const parsed = Number(params.page ?? 1);
const page =
Number.isSafeInteger(parsed) && parsed > 0 ? Math.min(parsed, 100000) : 1;
const pageSize = 12;
const items = await getAnnouncements(session, {
take: pageSize + 1,
skip: (page - 1) * pageSize,
});
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-deep)]">
SCHWARZES BRETT
</p>
<h1 className="mt-2 text-[36px] tracking-[-0.025em]">
Alle{" "}
<em className="font-normal text-[var(--accent-deep)]">
Mitteilungen
</em>
</h1>
<p className="mt-2 text-[15px] text-[var(--ink-soft)]">
Aktuelle und ältere Meldungen für deine Empfängergruppe.
</p>
</header>
<NewsTicker items={items.slice(0, pageSize)} archive />
<nav
aria-label="Mitteilungen durchblättern"
className="flex items-center justify-between gap-3"
>
<div>
{page > 1 ? (
<Button asChild variant="outline">
<Link href={`/dashboard/mitteilungen?page=${page - 1}`}>
Neuere Meldungen
</Link>
</Button>
) : null}
</div>
<span className="text-sm text-[var(--ink-soft)]">Seite {page}</span>
<div>
{items.length > pageSize ? (
<Button asChild variant="outline">
<Link href={`/dashboard/mitteilungen?page=${page + 1}`}>
Ältere Meldungen
</Link>
</Button>
) : null}
</div>
</nav>
</div>
);
}
+48 -17
View File
@@ -1,5 +1,7 @@
"use client";
import Link from "next/link";
import { toast } from "sonner";
import { useState, useTransition } from "react";
import { Bell, FileText, Megaphone } from "lucide-react";
@@ -26,6 +28,7 @@ export type NewsTickerItemDto = {
id: string;
title: string;
content: string;
excerpt: string;
createdAt: string;
authorName: string;
isUnread: boolean;
@@ -37,22 +40,32 @@ export type NewsTickerItemDto = {
}[];
};
export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
export function NewsTicker({
items,
archive = false,
}: {
items: NewsTickerItemDto[];
archive?: boolean;
}) {
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);
try {
const result = await markAnnouncementRead(item.id);
if (result.error) {
toast.error(result.error);
return;
}
setReadIds((current) => new Set(current).add(item.id));
} catch {
toast.error("Lesestatus konnte nicht gespeichert werden.");
}
});
}
@@ -60,20 +73,33 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
<>
<Card className="mb-8 border-[var(--good)] bg-[var(--good-soft)]">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2 text-lg">
<Megaphone className="h-5 w-5 text-[var(--good)] [stroke-width:1.7]" />
Schwarzes Brett
{archive ? "Mitteilungen" : "Neues aus der Kita"}
</CardTitle>
<CardDescription>
Die neuesten offiziellen Ankündigungen deiner Kita.
{archive
? "Veröffentlichte Ankündigungen deiner Kita."
: "Die neuesten offiziellen Ankündigungen deiner Kita."}
</CardDescription>
</div>
<Badge variant="secondary">{items.length}</Badge>
{archive ? (
<Badge variant="secondary">{items.length} Meldungen</Badge>
) : (
<Button asChild variant="outline" size="sm">
<Link href="/dashboard/mitteilungen">Alle Mitteilungen</Link>
</Button>
)}
</div>
</CardHeader>
<CardContent className="grid gap-3 lg:grid-cols-3">
{items.length === 0 ? (
<p className="col-span-full py-4 text-sm text-[var(--ink-soft)]">
Noch keine Mitteilungen für deine Empfängergruppe.
</p>
) : null}
{items.map((item) => {
const isUnread = item.isUnread && !readIds.has(item.id);
return (
@@ -88,15 +114,13 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
<Bell className="h-3.5 w-3.5 [stroke-width:1.7]" />
{item.createdAt}
</div>
{isUnread && (
<Badge variant="success">NEU</Badge>
)}
{isUnread && <Badge variant="success">NEU</Badge>}
</div>
<h3 className="line-clamp-2 font-semibold leading-snug text-[var(--ink)]">
{item.title}
</h3>
<p className="mt-2 line-clamp-3 text-sm leading-6 text-[var(--ink-soft)]">
{item.content}
{item.excerpt}
</p>
{item.attachments.length > 0 && (
<p className="mt-3 flex items-center gap-1 text-xs font-medium text-[var(--accent-deep)]">
@@ -111,7 +135,10 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
</CardContent>
</Card>
<Dialog open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
<Dialog
open={!!selected}
onOpenChange={(open) => !open && setSelected(null)}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
{selected && (
<>
@@ -137,7 +164,11 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
variant="outline"
className="justify-start"
>
<a href={attachment.fileUrl} target="_blank" rel="noreferrer">
<a
href={attachment.fileUrl}
target="_blank"
rel="noreferrer"
>
<FileText className="h-4 w-4 [stroke-width:1.7]" />
{attachment.fileName}
</a>
+13 -2
View File
@@ -18,6 +18,10 @@ import { getTargetMonthData } from "@/lib/date-utils";
import { prisma } from "@/lib/prisma";
import { Stat } from "@/components/ui/stat";
import { OnboardingDialog } from "@/components/OnboardingDialog";
import { NewsTicker } from "./news-ticker";
import { RsvpActionCard } from "./rsvp-action-card";
import { getAnnouncements } from "@/lib/announcements";
import { getRsvpEvents } from "@/lib/rsvp-events";
import { AbsenceCard, UpcomingAbsencesCard } from "./absence-card";
import { AlarmDashboardHints } from "./notdienst/alarm/dashboard-hints";
@@ -56,6 +60,8 @@ export default async function DashboardPage() {
onboardingUser,
absenceChildren,
upcomingAbsences,
announcements,
rsvpEvents,
] = await Promise.all([
prisma.family.count({
where: { kitaId: session.user.kitaId },
@@ -143,6 +149,8 @@ export default async function DashboardPage() {
take: 6,
})
: Promise.resolve([]),
getAnnouncements(session, { take: 3 }),
getRsvpEvents(session, { onlyOpen: true }),
]);
const canReportAbsences = session.user.role !== UserRole.ERZIEHER;
@@ -178,7 +186,7 @@ export default async function DashboardPage() {
}
/>
<header className="mb-9 flex items-start justify-between gap-8">
<header className="mb-9 flex items-start justify-between gap-8 max-[760px]:flex-col max-[760px]:gap-3">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
ÜBERSICHT · {kita.name}
@@ -195,13 +203,16 @@ export default async function DashboardPage() {
siehst du auf einen Blick, was bei {kita.name} ansteht.
</p>
</div>
<div className="mt-9 inline-flex shrink-0 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 py-2 text-[13px] font-semibold text-[var(--ink-soft)]">
<div className="mt-9 max-[760px]:mt-0 inline-flex shrink-0 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 py-2 text-[13px] font-semibold text-[var(--ink-soft)]">
<Clock3 className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(now, "EEE, d. MMM yyyy", { locale: de })}
</div>
</header>
<AlarmDashboardHints />
<RsvpActionCard events={rsvpEvents} />
<NewsTicker items={announcements} />
<div className="grid grid-cols-[minmax(0,1fr)_360px] gap-5 max-[1100px]:grid-cols-1">
<div className="min-w-0">
{session.user.role === UserRole.ERZIEHER && (
+12 -167
View File
@@ -1,15 +1,10 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { EventParticipationStatus } from "@prisma/client";
import { AlertTriangle } from "lucide-react";
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 type { RsvpEventDto } from "@/lib/rsvp";
import { RsvpDialog } from "@/components/rsvp-form";
import {
Card,
CardContent,
@@ -17,41 +12,18 @@ import {
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[] }) {
export function RsvpActionCard({ events }: { events: RsvpEventDto[] }) {
if (events.length === 0) return null;
return (
<Card className="mb-8 border-[var(--warn)] bg-[var(--warn-soft)]">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg text-[var(--ink)]">
<AlertTriangle className="h-5 w-5 text-[var(--warn)] [stroke-width:1.7]" />
Aktion erforderlich
<Card className="mb-5 border-[var(--warn)] bg-[var(--warn-soft)]">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<AlertTriangle className="h-5 w-5 text-[var(--warn)]" />
Rückmeldung erforderlich
</CardTitle>
<CardDescription className="text-[var(--ink-soft)]">
Für diese Termine fehlt noch mindestens eine Rückmeldung deiner
Familie.
<CardDescription>
Für diese Termine fehlt noch eine Antwort eurer Familie.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
@@ -61,7 +33,7 @@ export function RsvpActionCard({ events }: { events: DashboardRsvpEventDto[] })
className="flex flex-col gap-3 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p className="font-medium text-[var(--ink)]">{event.title}</p>
<p className="font-medium">{event.title}</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Rückmeldung bis{" "}
{format(
@@ -78,130 +50,3 @@ export function RsvpActionCard({ events }: { events: DashboardRsvpEventDto[] })
</Card>
);
}
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<string, EventParticipationStatus | "">,
[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 (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (nextOpen) setSelection(initialSelection);
}}
>
<DialogTrigger asChild>
<Button size="sm">Jetzt abstimmen</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Rückmeldung für {event.title}</DialogTitle>
<DialogDescription>
Wähle pro Kind aus, ob es am Termin teilnimmt.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-2">
{event.children.map((child) => (
<div
key={child.id}
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4"
>
<div className="mb-3 flex items-center justify-between gap-3">
<p className="font-medium text-[var(--ink)]">{child.name}</p>
{child.status && (
<Badge variant="outline">bereits gemeldet</Badge>
)}
</div>
<div className="grid gap-2 sm:grid-cols-2">
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-3 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]">
<input
type="radio"
name={`rsvp-${child.id}`}
checked={
selection[child.id] ===
EventParticipationStatus.ATTENDING
}
onChange={() =>
setSelection((current) => ({
...current,
[child.id]: EventParticipationStatus.ATTENDING,
}))
}
/>
<Check className="h-4 w-4 text-[var(--good)] [stroke-width:1.7]" />
Nimmt teil
</label>
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-3 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]">
<input
type="radio"
name={`rsvp-${child.id}`}
checked={
selection[child.id] ===
EventParticipationStatus.NOT_ATTENDING
}
onChange={() =>
setSelection((current) => ({
...current,
[child.id]: EventParticipationStatus.NOT_ATTENDING,
}))
}
/>
<X className="h-4 w-4 text-[var(--danger)] [stroke-width:1.7]" />
Nimmt nicht teil
</label>
</div>
</div>
))}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
Abbrechen
</Button>
<Button type="button" disabled={isPending} onClick={submit}>
{isPending ? "Speichern..." : "Rückmeldung speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+7
View File
@@ -44,6 +44,13 @@ const navItems = [
icon: LayoutDashboard,
exact: true,
},
{
section: "Heute",
href: "/dashboard/mitteilungen",
label: "Mitteilungen",
icon: Megaphone,
exact: false,
},
{
section: "Heute",
href: "/dashboard/notdienst",
+223
View File
@@ -0,0 +1,223 @@
"use client";
import { useId, useState, useTransition } from "react";
import { EventParticipationStatus, RsvpMode } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { toast } from "sonner";
import { submitEventRsvp } from "@/actions/rsvp";
import { hasMissingRsvp, type RsvpEventDto } from "@/lib/rsvp";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export function RsvpDialog({ event }: { event: RsvpEventDto }) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">
{!event.canReply
? "Rückmeldung ansehen"
: hasMissingRsvp(event)
? "Jetzt antworten"
: "Antwort ändern"}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Rückmeldung für {event.title}</DialogTitle>
<DialogDescription>
{event.mode === RsvpMode.FAMILY
? "Eine gemeinsame Antwort für eure Familie."
: "Eine Rückmeldung für jedes eigene Kind."}
</DialogDescription>
</DialogHeader>
<RsvpForm
key={JSON.stringify(event)}
event={event}
onSaved={() => setOpen(false)}
/>
</DialogContent>
</Dialog>
);
}
export function RsvpForm({
event,
onSaved,
}: {
event: RsvpEventDto;
onSaved?: () => void;
}) {
const id = useId();
const [isPending, startTransition] = useTransition();
const [familyStatus, setFamilyStatus] = useState<
EventParticipationStatus | ""
>(event.familyResponse?.status ?? "");
const [adultCount, setAdultCount] = useState(
String(event.familyResponse?.adultCount || 1),
);
const [selection, setSelection] = useState<
Record<string, EventParticipationStatus | "">
>(
Object.fromEntries(
event.children.map((child) => [child.id, child.status ?? ""]),
),
);
const isFamily = event.mode === RsvpMode.FAMILY;
const targets = isFamily
? [{ id: "family", name: "Eure Familie", status: familyStatus }]
: event.children.map((child) => ({
...child,
status: selection[child.id],
}));
return (
<form
className="grid gap-4"
onSubmit={(e) => {
e.preventDefault();
if (!event.canReply || isPending) return;
if (targets.some((target) => !target.status)) {
toast.error("Bitte für alle eine Antwort auswählen.");
return;
}
const count =
familyStatus === EventParticipationStatus.ATTENDING
? Number(adultCount)
: 0;
if (
isFamily &&
(!Number.isSafeInteger(count) ||
count <
(familyStatus === EventParticipationStatus.ATTENDING ? 1 : 0))
) {
toast.error("Bitte eine gültige Anzahl Erwachsener angeben.");
return;
}
startTransition(async () => {
const result = await submitEventRsvp(
isFamily
? {
eventId: event.id,
mode: RsvpMode.FAMILY,
status: familyStatus,
adultCount: count,
}
: {
eventId: event.id,
mode: RsvpMode.CHILD,
responses: event.children.map((child) => ({
childId: child.id,
status: selection[child.id],
})),
},
);
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Rückmeldung gespeichert.");
onSaved?.();
});
}}
>
<p className="text-sm text-[var(--ink-soft)]">
{isFamily ? "Antwort pro Familie" : "Antwort pro Kind"} · Rückmeldung
bis{" "}
{format(
new Date(event.rsvpDeadline ?? event.startDate),
"dd.MM.yyyy HH:mm",
{ locale: de },
)}
</p>
<fieldset
disabled={isPending || !event.canReply}
className="grid gap-3 disabled:opacity-75"
>
<legend className="sr-only">Teilnahme auswählen</legend>
{targets.map((target) => (
<fieldset
key={target.id}
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4"
>
<legend className="px-1 text-sm font-medium text-[var(--ink)]">
{target.name}
</legend>
<div className="grid gap-2 sm:grid-cols-2">
{(
[
EventParticipationStatus.ATTENDING,
EventParticipationStatus.NOT_ATTENDING,
] as const
).map((status) => (
<label
key={status}
className="flex cursor-pointer items-center gap-2 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-3 text-sm"
>
<input
type="radio"
name={`${id}-${target.id}`}
value={status}
required
checked={target.status === status}
onChange={() =>
isFamily
? setFamilyStatus(status)
: setSelection((current) => ({
...current,
[target.id]: status,
}))
}
/>
{status === EventParticipationStatus.ATTENDING
? "Nimmt teil"
: "Nimmt nicht teil"}
</label>
))}
</div>
</fieldset>
))}
{isFamily && familyStatus === EventParticipationStatus.ATTENDING ? (
<div className="grid gap-2">
<Label htmlFor={`${id}-adults`}>
Anzahl teilnehmender Erwachsener
</Label>
<Input
id={`${id}-adults`}
type="number"
min={1}
step={1}
required
value={adultCount}
onChange={(e) => setAdultCount(e.target.value)}
/>
</div>
) : null}
</fieldset>
<p className="text-xs text-[var(--ink-soft)]">
Die Antwort gilt für euren gemeinsamen Haushalt und ist für beide
Elternkonten sichtbar.
</p>
{event.canReply ? (
<Button type="submit" disabled={isPending}>
{isPending ? "Speichern…" : "Rückmeldung speichern"}
</Button>
) : (
<p className="text-sm font-medium text-[var(--ink-muted)]">
Die Rückmeldefrist ist abgelaufen. Gespeicherte Antworten bleiben
sichtbar.
</p>
)}
</form>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { AnnouncementAudience, UserRole } from "@prisma/client";
// Admins need access to all publications for management. Other roles only
// receive their audience; a parent with a coordination role is still a parent.
export function visibleAnnouncementAudiences(user: {
role: UserRole;
familyId?: string | null;
}): AnnouncementAudience[] {
if (user.role === UserRole.ADMIN) return Object.values(AnnouncementAudience);
const audiences: AnnouncementAudience[] = [AnnouncementAudience.ALLE];
if (user.role === UserRole.ERZIEHER)
audiences.push(AnnouncementAudience.ERZIEHER);
if (
user.role !== UserRole.ERZIEHER &&
(user.familyId || user.role === UserRole.ELTERN)
) {
audiences.push(AnnouncementAudience.ELTERN);
}
return audiences;
}
export function announcementRecipientWhere(audience: AnnouncementAudience) {
const parents = {
role: { not: UserRole.ERZIEHER },
OR: [{ familyId: { not: null } }, { role: UserRole.ELTERN }],
};
if (audience === AnnouncementAudience.ELTERN) return parents;
if (audience === AnnouncementAudience.ERZIEHER)
return { role: UserRole.ERZIEHER };
return { OR: [parents, { role: UserRole.ERZIEHER }] };
}
export const announcementAudienceLabels: Record<AnnouncementAudience, string> =
{
ELTERN: "Eltern",
ERZIEHER: "ErzieherInnen",
ALLE: "Eltern und ErzieherInnen",
};
+48
View File
@@ -0,0 +1,48 @@
import "server-only";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import type { KitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { visibleAnnouncementAudiences } from "@/lib/announcement-access";
export async function getAnnouncements(
session: KitaSession,
options: { take: number; skip?: number },
) {
const rows = await prisma.announcement.findMany({
where: {
kitaId: session.user.kitaId,
audience: { in: visibleAnnouncementAudiences(session.user) },
},
select: {
id: true,
title: true,
content: true,
createdAt: true,
author: { select: { firstName: true, lastName: true } },
reads: { where: { userId: session.user.id }, select: { id: true } },
attachments: {
select: { id: true, fileName: true, fileUrl: true, fileType: true },
},
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: options.take,
skip: options.skip,
});
return rows.map((row) => ({
id: row.id,
title: row.title,
content: row.content,
excerpt: row.content
.replace(/^#{1,6}\s+/gm, "")
.replace(/!?(\[([^\]]+)\])\([^)]+\)/g, "$2")
.replace(/(\*\*|__|`)/g, "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 300),
createdAt: format(row.createdAt, "dd.MM.yyyy HH:mm", { locale: de }),
authorName: `${row.author.firstName} ${row.author.lastName}`,
isUnread: row.reads.length === 0,
attachments: row.attachments,
}));
}
+10
View File
@@ -0,0 +1,10 @@
import type { Prisma } from "@prisma/client";
// Serialize RSVP writes and changes of the response mode on the same event.
export async function lockEvent(
tx: Prisma.TransactionClient,
eventId: string,
kitaId: string,
) {
await tx.$queryRaw`SELECT id FROM termine WHERE id = ${eventId} AND "kitaId" = ${kitaId} FOR UPDATE`;
}
+77
View File
@@ -0,0 +1,77 @@
import "server-only";
import { RsvpMode, TerminStatus, UserRole } from "@prisma/client";
import type { KitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { hasMissingRsvp, type RsvpEventDto } from "@/lib/rsvp";
export async function getRsvpEvents(
session: KitaSession,
options: { onlyOpen?: boolean } = {},
): Promise<RsvpEventDto[]> {
if (!session.user.familyId || session.user.role === UserRole.ERZIEHER)
return [];
const now = new Date();
const familyId = session.user.familyId;
const [events, children] = await Promise.all([
prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
requiresRsvp: true,
status: TerminStatus.CONFIRMED,
kita: { terminModuleEnabled: true },
...(options.onlyOpen
? {
startDate: { gt: now },
OR: [{ rsvpDeadline: null }, { rsvpDeadline: { gt: now } }],
}
: {}),
},
select: {
id: true,
title: true,
startDate: true,
rsvpDeadline: true,
rsvpMode: true,
participations: {
where: { child: { familyId, active: true } },
select: { childId: true, status: true },
},
familyParticipations: {
where: { familyId },
select: { status: true, adultCount: true },
},
},
orderBy: { startDate: "asc" },
}),
prisma.child.findMany({
where: { kitaId: session.user.kitaId, familyId, active: true },
select: { id: true, firstName: true, lastName: true },
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
}),
]);
const rows: RsvpEventDto[] = events
.filter(
(event) => event.rsvpMode === RsvpMode.FAMILY || children.length > 0,
)
.map((event) => ({
id: event.id,
title: event.title,
startDate: event.startDate.toISOString(),
rsvpDeadline: event.rsvpDeadline?.toISOString() ?? null,
mode: event.rsvpMode,
canReply:
(event.rsvpDeadline ?? event.startDate) > now && event.startDate > now,
familyResponse: event.familyParticipations[0] ?? null,
children:
event.rsvpMode === RsvpMode.CHILD
? children.map((child) => ({
id: child.id,
name: `${child.firstName} ${child.lastName}`,
status:
event.participations.find((row) => row.childId === child.id)
?.status ?? null,
}))
: [],
}));
return options.onlyOpen ? rows.filter(hasMissingRsvp) : rows;
}
+70
View File
@@ -0,0 +1,70 @@
import { EventParticipationStatus, RsvpMode, TerminType } from "@prisma/client";
import { z } from "zod";
export const rsvpSubmissionSchema = z.discriminatedUnion("mode", [
z.object({
eventId: z.string().min(1),
mode: z.literal(RsvpMode.CHILD),
responses: z
.array(
z.object({
childId: z.string().min(1),
status: z.nativeEnum(EventParticipationStatus),
}),
)
.min(1)
.refine(
(rows) => new Set(rows.map((row) => row.childId)).size === rows.length,
"Doppelte Kinderantwort",
),
}),
z
.object({
eventId: z.string().min(1),
mode: z.literal(RsvpMode.FAMILY),
status: z.nativeEnum(EventParticipationStatus),
adultCount: z.number().int().min(0).max(2147483647),
})
.refine(
(row) =>
row.status === EventParticipationStatus.ATTENDING
? row.adultCount >= 1
: row.adultCount === 0,
"Bei Zusage bitte die Anzahl Erwachsener angeben; bei Absage muss sie null sein.",
),
]);
export function suggestedRsvpMode(type: TerminType): RsvpMode {
return new Set<TerminType>([
TerminType.ELTERNABEND,
TerminType.MITGLIEDERVERSAMMLUNG,
TerminType.ELTERNCAFE,
TerminType.KITA_FEST,
]).has(type)
? RsvpMode.FAMILY
: RsvpMode.CHILD;
}
export type RsvpEventDto = {
id: string;
title: string;
startDate: string;
rsvpDeadline: string | null;
mode: RsvpMode;
canReply: boolean;
familyResponse: {
status: EventParticipationStatus;
adultCount: number;
} | null;
children: {
id: string;
name: string;
status: EventParticipationStatus | null;
}[];
};
export function hasMissingRsvp(event: RsvpEventDto) {
return event.mode === RsvpMode.FAMILY
? !event.familyResponse
: event.children.some((child) => !child.status);
}
+415
View File
@@ -0,0 +1,415 @@
import { strict as assert } from "node:assert";
import { mock, test } from "node:test";
import { createRequire } from "node:module";
import { resolve } from "node:path";
import { randomUUID } from "node:crypto";
import {
AnnouncementAudience,
EventParticipationStatus as S,
PrismaClient,
RsvpMode,
TerminStatus,
TerminType,
UserRole,
} from "@prisma/client";
// No database work without an explicitly supplied local test database.
test(
"Elternabläufe gegen PostgreSQL",
{ skip: !process.env.TEST_DATABASE_URL },
async (t) => {
const url = new URL(process.env.TEST_DATABASE_URL!);
assert.ok(
["127.0.0.1", "localhost"].includes(url.hostname),
"Nur lokale Testdatenbanken sind erlaubt",
);
assert.ok(
/test|parent_flows/.test(url.pathname),
"Der Datenbankname muss ausdrücklich eine Testdatenbank kennzeichnen",
);
process.env.DATABASE_URL = url.toString();
const db = new PrismaClient();
const id = randomUUID();
const kitaId = `parent-test-${id}`;
const otherKitaId = `other-test-${id}`;
let user = {
id: `parent-a-${id}`,
kitaId,
familyId: `family-${id}`,
role: UserRole.ELTERN as UserRole,
};
const moduleMock = mock as typeof mock & {
module: (
specifier: string,
options: { namedExports: Record<string, unknown> },
) => unknown;
};
moduleMock.module("../src/lib/auth-utils.ts", {
namedExports: {
requireKitaSession: async () => ({ user }),
requireRole: async (roles: UserRole[]) => {
assert.ok(roles.includes(user.role));
return { user };
},
},
});
moduleMock.module("../src/auth.ts", {
namedExports: { auth: async () => ({ user }) },
});
moduleMock.module("next/cache", {
namedExports: { revalidatePath: () => {} },
});
const loadAfterMocks = createRequire(
resolve("tests/parent-flows.integration.test.ts"),
);
const { submitEventRsvp } = loadAfterMocks(
"../src/actions/rsvp",
) as typeof import("../src/actions/rsvp");
const { updateTerminAdmin } = loadAfterMocks(
"../src/app/dashboard/kalender/actions",
) as typeof import("../src/app/dashboard/kalender/actions");
const { createAnnouncement, markAnnouncementRead } = loadAfterMocks(
"../src/actions/announcements",
) as typeof import("../src/actions/announcements");
const { GET: getAttachment } = loadAfterMocks(
"../src/app/api/announcements/attachments/[id]/route",
) as typeof import("../src/app/api/announcements/attachments/[id]/route");
const familyId = user.familyId;
const startDate = new Date(Date.now() + 86400000 * 3);
const endDate = new Date(startDate.getTime() + 3600000);
const eventBase = {
kitaId,
title: "Testtermin",
type: TerminType.SONSTIGES,
status: TerminStatus.CONFIRMED,
startDate,
endDate,
requiresRsvp: true,
};
const updateBase = {
title: "Testtermin geändert",
type: TerminType.SONSTIGES,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
};
let teacherAnnouncementId = "";
try {
await db.kita.create({
data: { id: kitaId, name: "Elternabläufe Test", slug: kitaId },
});
await db.kita.create({
data: { id: otherKitaId, name: "Andere Kita", slug: otherKitaId },
});
await db.family.create({
data: { id: familyId, kitaId, name: "Testfamilie" },
});
await db.family.create({
data: {
id: `other-family-${id}`,
kitaId: otherKitaId,
name: "Fremde Familie",
},
});
await db.user.create({
data: {
id: user.id,
kitaId,
familyId,
email: `${id}@example.test`,
passwordHash: "test",
firstName: "Test",
lastName: "Eltern",
},
});
const children = await Promise.all(
["a", "b"].map((suffix) =>
db.child.create({
data: {
id: `${suffix}-${id}`,
kitaId,
familyId,
firstName: suffix,
lastName: "Test",
},
}),
),
);
const foreign = await db.child.create({
data: {
kitaId: otherKitaId,
familyId: `other-family-${id}`,
firstName: "Fremd",
lastName: "Test",
},
});
const inactive = await db.child.create({
data: {
kitaId,
familyId,
firstName: "Inaktiv",
lastName: "Test",
active: false,
},
});
const familyEvent = await db.termin.create({
data: { ...eventBase, rsvpMode: RsvpMode.FAMILY },
});
const childEvent = await db.termin.create({
data: {
...eventBase,
rsvpMode: RsvpMode.CHILD,
mitbringselListEnabled: true,
participantInfo: "Nur Teilnehmende",
},
});
const familyPayload = {
eventId: familyEvent.id,
mode: RsvpMode.FAMILY,
status: S.ATTENDING,
adultCount: 2,
};
const childPayload = {
eventId: childEvent.id,
mode: RsvpMode.CHILD,
responses: [
{ childId: children[0].id, status: S.ATTENDING },
{ childId: children[1].id, status: S.NOT_ATTENDING },
],
};
await t.test(
"Zwei Elternkonten ändern eine gemeinsame Familienantwort",
async () => {
assert.equal((await submitEventRsvp(familyPayload)).success, true);
user = { ...user, id: `parent-b-${id}` };
assert.equal(
(await submitEventRsvp({ ...familyPayload, adultCount: 3 }))
.success,
true,
);
assert.equal(
await db.familyEventParticipation.count({
where: { eventId: familyEvent.id },
}),
1,
);
assert.equal(
(
await db.familyEventParticipation.findFirstOrThrow({
where: { eventId: familyEvent.id },
})
).adultCount,
3,
);
assert.ok(
(await submitEventRsvp({ ...familyPayload, adultCount: 0 })).error,
);
assert.equal(
(
await submitEventRsvp({
...familyPayload,
status: S.NOT_ATTENDING,
adultCount: 0,
})
).success,
true,
);
assert.equal(
(
await db.familyEventParticipation.findFirstOrThrow({
where: { eventId: familyEvent.id },
})
).adultCount,
0,
);
},
);
await t.test(
"Alle Kinder werden gemeinsam gespeichert, fremde/inaktive Kinder abgewiesen",
async () => {
assert.equal((await submitEventRsvp(childPayload)).success, true);
for (const childId of [foreign.id, inactive.id]) {
assert.ok(
(
await submitEventRsvp({
...childPayload,
responses: [
{ childId: children[0].id, status: S.NOT_ATTENDING },
{ childId, status: S.ATTENDING },
],
})
).error,
);
}
assert.ok(
(
await submitEventRsvp({
...childPayload,
responses: [childPayload.responses[0]],
})
).error,
);
const rows = await db.eventParticipation.findMany({
where: { eventId: childEvent.id },
orderBy: { childId: "asc" },
});
assert.equal(rows.length, 2);
assert.equal(rows[0].status, S.ATTENDING);
assert.equal(rows[1].status, S.NOT_ATTENDING);
},
);
await t.test(
"Falsche Antwortart und fremde Kita können nicht beschrieben werden",
async () => {
assert.ok(
(
await submitEventRsvp({
...familyPayload,
eventId: childEvent.id,
})
).error,
);
user = { ...user, kitaId: otherKitaId };
assert.ok((await submitEventRsvp(familyPayload)).error);
user = { ...user, kitaId };
},
);
await t.test(
"Frist, Absage und deaktiviertes Terminmodul verhindern Antworten",
async () => {
await db.termin.update({
where: { id: familyEvent.id },
data: { rsvpDeadline: new Date(Date.now() - 1000) },
});
assert.ok((await submitEventRsvp(familyPayload)).error);
await db.termin.update({
where: { id: familyEvent.id },
data: { rsvpDeadline: null, status: TerminStatus.CANCELLED },
});
assert.ok((await submitEventRsvp(familyPayload)).error);
await db.termin.update({
where: { id: familyEvent.id },
data: { status: TerminStatus.CONFIRMED },
});
await db.kita.update({
where: { id: kitaId },
data: { terminModuleEnabled: false },
});
assert.ok((await submitEventRsvp(familyPayload)).error);
await db.kita.update({
where: { id: kitaId },
data: { terminModuleEnabled: true },
});
},
);
await t.test(
"Normale Terminänderung erhält RSVP-Felder; Antwortart ist nach Antwort gesperrt",
async () => {
user = { ...user, role: UserRole.ADMIN };
assert.equal(
(await updateTerminAdmin(childEvent.id, updateBase)).success,
true,
);
const event = await db.termin.findUniqueOrThrow({
where: { id: childEvent.id },
});
assert.equal(event.requiresRsvp, true);
assert.equal(event.mitbringselListEnabled, true);
assert.equal(event.participantInfo, "Nur Teilnehmende");
assert.ok(
(
await updateTerminAdmin(childEvent.id, {
...updateBase,
rsvpMode: RsvpMode.FAMILY,
})
).error,
);
},
);
await t.test(
"Gleichzeitige erste Antwort und Modusänderung bleiben widerspruchsfrei",
async () => {
const event = await db.termin.create({
data: { ...eventBase, rsvpMode: RsvpMode.FAMILY },
});
await Promise.all([
submitEventRsvp({ ...familyPayload, eventId: event.id }),
updateTerminAdmin(event.id, {
...updateBase,
rsvpMode: RsvpMode.CHILD,
}),
]);
const stored = await db.termin.findUniqueOrThrow({
where: { id: event.id },
include: { familyParticipations: true },
});
assert.ok(
stored.rsvpMode === RsvpMode.FAMILY
? stored.familyParticipations.length === 1
: stored.familyParticipations.length === 0,
);
},
);
await t.test(
"Empfängergruppe wird auch ohne Mail gespeichert",
async () => {
user = { ...user, id: `parent-a-${id}`, role: UserRole.ADMIN };
const form = new FormData();
form.set("title", "Nur ErzieherInnen");
form.set("content", "Interne Teammitteilung");
form.set("audience", "ERZIEHER");
assert.equal((await createAnnouncement(form)).success, true);
const announcement = await db.announcement.findFirstOrThrow({
where: { kitaId, title: "Nur ErzieherInnen" },
});
assert.equal(announcement.audience, AnnouncementAudience.ERZIEHER);
teacherAnnouncementId = announcement.id;
await db.attachment.create({
data: {
id: `teacher-attachment-${id}`,
announcementId: announcement.id,
fileName: "team.pdf",
fileUrl: `/api/announcements/attachments/teacher-attachment-${id}`,
fileType: "application/pdf",
},
});
},
);
await t.test(
"Eltern können Teammitteilungen weder als gelesen markieren noch Anhänge abrufen",
async () => {
user = { ...user, role: UserRole.ELTERN };
assert.ok((await markAnnouncementRead(teacherAnnouncementId)).error);
const response = await getAttachment(
new Request("http://localhost/test"),
{ params: Promise.resolve({ id: `teacher-attachment-${id}` }) },
);
assert.equal(response.status, 404);
assert.equal(
await db.announcementRead.count({
where: { announcementId: teacherAnnouncementId },
}),
0,
);
user = { ...user, role: UserRole.ERZIEHER };
assert.equal(
(await markAnnouncementRead(teacherAnnouncementId)).success,
true,
);
user = { ...user, kitaId: otherKitaId };
assert.ok((await markAnnouncementRead(teacherAnnouncementId)).error);
},
);
} finally {
await db.kita.deleteMany({
where: { id: { in: [kitaId, otherKitaId] } },
});
await db.$disconnect();
const { prisma } = loadAfterMocks(
"../src/lib/prisma",
) as typeof import("../src/lib/prisma");
await prisma.$disconnect();
mock.restoreAll();
}
},
);
+143
View File
@@ -0,0 +1,143 @@
import { strict as assert } from "node:assert";
import { test } from "node:test";
import {
AnnouncementAudience as A,
EventParticipationStatus as S,
RsvpMode,
TerminType,
UserRole,
} from "@prisma/client";
import {
announcementRecipientWhere,
visibleAnnouncementAudiences,
} from "../src/lib/announcement-access";
import {
hasMissingRsvp,
rsvpSubmissionSchema,
suggestedRsvpMode,
type RsvpEventDto,
} from "../src/lib/rsvp";
const family = {
eventId: "event",
mode: RsvpMode.FAMILY,
status: S.ATTENDING,
adultCount: 2,
};
test("Familienzusage braucht eine positive ganze Erwachsenenanzahl", () => {
assert.equal(rsvpSubmissionSchema.safeParse(family).success, true);
for (const adultCount of [0, -1, 1.5, 2147483648, NaN]) {
assert.equal(
rsvpSubmissionSchema.safeParse({ ...family, adultCount }).success,
false,
);
}
});
test("Familienabsage setzt Erwachsenenanzahl auf null", () => {
assert.equal(
rsvpSubmissionSchema.safeParse({
...family,
status: S.NOT_ATTENDING,
adultCount: 0,
}).success,
true,
);
assert.equal(
rsvpSubmissionSchema.safeParse({ ...family, status: S.NOT_ATTENDING })
.success,
false,
);
});
test("Kinderantworten dürfen verschiedene Zusagen enthalten, keine Duplikate oder leere Liste", () => {
const payload = {
eventId: "event",
mode: RsvpMode.CHILD,
responses: [
{ childId: "a", status: S.ATTENDING },
{ childId: "b", status: S.NOT_ATTENDING },
],
};
assert.equal(rsvpSubmissionSchema.safeParse(payload).success, true);
assert.equal(
rsvpSubmissionSchema.safeParse({ ...payload, responses: [] }).success,
false,
);
assert.equal(
rsvpSubmissionSchema.safeParse({
...payload,
responses: [payload.responses[0], payload.responses[0]],
}).success,
false,
);
});
test("Eltern sehen Elternmeldungen und gemeinsame Meldungen", () => {
assert.deepEqual(
visibleAnnouncementAudiences({ role: UserRole.ELTERN, familyId: "f" }),
[A.ALLE, A.ELTERN],
);
assert.deepEqual(
visibleAnnouncementAudiences({ role: UserRole.KOORDINATOR, familyId: "f" }),
[A.ALLE, A.ELTERN],
);
});
test("Erzieherkonto erhält auch mit Haushaltszuordnung keine Elternmeldungen", () => {
assert.deepEqual(
visibleAnnouncementAudiences({ role: UserRole.ERZIEHER, familyId: "f" }),
[A.ALLE, A.ERZIEHER],
);
});
test("Admin darf alle Meldungen verwalten", () => {
assert.deepEqual(
new Set(visibleAnnouncementAudiences({ role: UserRole.ADMIN })),
new Set(Object.values(A)),
);
});
test("E-Mail-Gruppen schließen Erzieher aus Elternempfängern aus", () => {
assert.deepEqual(announcementRecipientWhere(A.ERZIEHER), {
role: UserRole.ERZIEHER,
});
assert.deepEqual(announcementRecipientWhere(A.ELTERN), {
role: { not: UserRole.ERZIEHER },
OR: [{ familyId: { not: null } }, { role: UserRole.ELTERN }],
});
});
test("Die Rückmeldekarte verschwindet erst, wenn jedes Kind geantwortet hat", () => {
const event: RsvpEventDto = {
id: "e",
title: "Ausflug",
startDate: "2030-01-01",
rsvpDeadline: null,
mode: RsvpMode.CHILD,
canReply: true,
familyResponse: null,
children: [
{ id: "a", name: "A", status: S.ATTENDING },
{ id: "b", name: "B", status: null },
],
};
assert.equal(hasMissingRsvp(event), true);
assert.equal(
hasMissingRsvp({
...event,
children: event.children.map((child) => ({
...child,
status: S.NOT_ATTENDING,
})),
}),
false,
);
assert.equal(hasMissingRsvp({ ...event, mode: RsvpMode.FAMILY }), true);
assert.equal(
hasMissingRsvp({
...event,
mode: RsvpMode.FAMILY,
familyResponse: { status: S.NOT_ATTENDING, adultCount: 0 },
}),
false,
);
});
test("Antwortart wird passend zur Terminart vorgeschlagen", () => {
assert.equal(suggestedRsvpMode(TerminType.ELTERNABEND), RsvpMode.FAMILY);
assert.equal(suggestedRsvpMode(TerminType.SONSTIGES), RsvpMode.CHILD);
});