feat: add parent announcements and family or child event responses
This commit is contained in:
@@ -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
@@ -16,7 +16,9 @@
|
|||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:studio": "prisma studio",
|
"prisma:studio": "prisma studio",
|
||||||
"db:seed": "NODE_ENV=development prisma db seed",
|
"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": {
|
"prisma": {
|
||||||
"seed": "tsx prisma/seed.ts"
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
|||||||
+39
-4
@@ -63,6 +63,17 @@ enum TerminStatus {
|
|||||||
CANCELLED
|
CANCELLED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RsvpMode {
|
||||||
|
CHILD
|
||||||
|
FAMILY
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AnnouncementAudience {
|
||||||
|
ELTERN
|
||||||
|
ERZIEHER
|
||||||
|
ALLE
|
||||||
|
}
|
||||||
|
|
||||||
enum EventParticipationStatus {
|
enum EventParticipationStatus {
|
||||||
ATTENDING
|
ATTENDING
|
||||||
NOT_ATTENDING
|
NOT_ATTENDING
|
||||||
@@ -156,6 +167,7 @@ model Kita {
|
|||||||
notdienstSwapRequests NotdienstSwapRequest[]
|
notdienstSwapRequests NotdienstSwapRequest[]
|
||||||
staffProfiles StaffProfile[]
|
staffProfiles StaffProfile[]
|
||||||
staffHolidays StaffHoliday[]
|
staffHolidays StaffHoliday[]
|
||||||
|
familyEventParticipations FamilyEventParticipation[]
|
||||||
|
|
||||||
@@map("kitas")
|
@@map("kitas")
|
||||||
}
|
}
|
||||||
@@ -180,6 +192,7 @@ model Family {
|
|||||||
children Child[]
|
children Child[]
|
||||||
dutyAssignments DutyAssignment[]
|
dutyAssignments DutyAssignment[]
|
||||||
notdienstSwapRequestsStarted NotdienstSwapRequest[] @relation("NotdienstSwapRequesterFamily")
|
notdienstSwapRequestsStarted NotdienstSwapRequest[] @relation("NotdienstSwapRequesterFamily")
|
||||||
|
familyEventParticipations FamilyEventParticipation[]
|
||||||
|
|
||||||
@@index([kitaId])
|
@@index([kitaId])
|
||||||
@@map("families")
|
@@map("families")
|
||||||
@@ -668,8 +681,9 @@ model Announcement {
|
|||||||
kitaId String
|
kitaId String
|
||||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
title String
|
title String
|
||||||
content String
|
content String
|
||||||
|
audience AnnouncementAudience @default(ALLE)
|
||||||
|
|
||||||
authorId String
|
authorId String
|
||||||
author User @relation("AnnouncementAuthor", fields: [authorId], references: [id], onDelete: Cascade)
|
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.
|
/// Teilnahme-Abfrage für Ausflüge und besondere Events.
|
||||||
requiresRsvp Boolean @default(false)
|
requiresRsvp Boolean @default(false)
|
||||||
|
rsvpMode RsvpMode @default(CHILD)
|
||||||
rsvpDeadline DateTime?
|
rsvpDeadline DateTime?
|
||||||
participantInfo String?
|
participantInfo String?
|
||||||
|
|
||||||
@@ -776,8 +791,9 @@ model Termin {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
mitbringselItems MitbringselItem[]
|
mitbringselItems MitbringselItem[]
|
||||||
participations EventParticipation[]
|
participations EventParticipation[]
|
||||||
|
familyParticipations FamilyEventParticipation[]
|
||||||
|
|
||||||
@@index([kitaId, startDate])
|
@@index([kitaId, startDate])
|
||||||
@@index([kitaId, status])
|
@@index([kitaId, status])
|
||||||
@@ -806,6 +822,25 @@ model EventParticipation {
|
|||||||
@@map("event_participations")
|
@@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 {
|
model MitbringselItem {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
kitaId String
|
kitaId String
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -5,13 +5,17 @@ import { mkdir, rm, writeFile } from "fs/promises";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { Prisma, UserRole } from "@prisma/client";
|
import { AnnouncementAudience, UserRole } from "@prisma/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { NewsEmail } from "@/emails/NewsEmail";
|
import { NewsEmail } from "@/emails/NewsEmail";
|
||||||
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
||||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
announcementRecipientWhere,
|
||||||
|
visibleAnnouncementAudiences,
|
||||||
|
} from "@/lib/announcement-access";
|
||||||
|
|
||||||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
|
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
|
||||||
const ALLOWED_TYPES = new Map([
|
const ALLOWED_TYPES = new Map([
|
||||||
@@ -55,25 +59,15 @@ function validateAttachment(file: File) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRecipients(kitaId: string, audience: z.infer<typeof audienceSchema>) {
|
async function getRecipients(
|
||||||
const where: Prisma.UserWhereInput =
|
kitaId: string,
|
||||||
audience === "ELTERN"
|
audience: z.infer<typeof audienceSchema>,
|
||||||
? {
|
) {
|
||||||
kitaId,
|
const where = {
|
||||||
familyId: { not: null },
|
kitaId,
|
||||||
emailVerifiedAt: { not: null },
|
emailVerifiedAt: { not: null },
|
||||||
}
|
...announcementRecipientWhere(audience as AnnouncementAudience),
|
||||||
: audience === "ERZIEHER"
|
};
|
||||||
? {
|
|
||||||
kitaId,
|
|
||||||
role: UserRole.ERZIEHER,
|
|
||||||
emailVerifiedAt: { not: null },
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
kitaId,
|
|
||||||
emailVerifiedAt: { not: null },
|
|
||||||
OR: [{ familyId: { not: null } }, { role: UserRole.ERZIEHER }],
|
|
||||||
};
|
|
||||||
|
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -129,6 +123,7 @@ export async function createAnnouncement(formData: FormData) {
|
|||||||
kitaId,
|
kitaId,
|
||||||
title: parsed.data.title,
|
title: parsed.data.title,
|
||||||
content: parsed.data.content,
|
content: parsed.data.content,
|
||||||
|
audience: parsed.data.audience,
|
||||||
authorId: session.user.id,
|
authorId: session.user.id,
|
||||||
attachments: {
|
attachments: {
|
||||||
create: attachmentInputs.map((attachment) => ({
|
create: attachmentInputs.map((attachment) => ({
|
||||||
@@ -166,7 +161,10 @@ export async function createAnnouncement(formData: FormData) {
|
|||||||
rm(path.join(uploadDir, attachment.id), { force: true }),
|
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." };
|
return { error: "Anhang konnte nicht gespeichert werden." };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,6 +196,7 @@ export async function createAnnouncement(formData: FormData) {
|
|||||||
|
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
revalidatePath("/dashboard/admin/news");
|
revalidatePath("/dashboard/admin/news");
|
||||||
|
revalidatePath("/dashboard/mitteilungen");
|
||||||
|
|
||||||
if (emailError) {
|
if (emailError) {
|
||||||
return {
|
return {
|
||||||
@@ -216,6 +215,7 @@ export async function markAnnouncementRead(announcementId: string) {
|
|||||||
where: {
|
where: {
|
||||||
id: announcementId,
|
id: announcementId,
|
||||||
kitaId: session.user.kitaId,
|
kitaId: session.user.kitaId,
|
||||||
|
audience: { in: visibleAnnouncementAudiences(session.user) },
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
@@ -241,5 +241,6 @@ export async function markAnnouncementRead(announcementId: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
|
revalidatePath("/dashboard/mitteilungen");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-77
@@ -3,20 +3,16 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import {
|
import {
|
||||||
EventParticipationStatus,
|
EventParticipationStatus,
|
||||||
|
RsvpMode,
|
||||||
TerminStatus,
|
TerminStatus,
|
||||||
UserRole,
|
UserRole,
|
||||||
} from "@prisma/client";
|
} 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 { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
||||||
import { prisma } from "@/lib/prisma";
|
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 = {
|
export type EventParticipantDto = {
|
||||||
childId: string;
|
childId: string;
|
||||||
childName: string;
|
childName: string;
|
||||||
@@ -24,79 +20,120 @@ export type EventParticipantDto = {
|
|||||||
status: EventParticipationStatus | null;
|
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 session = await requireKitaSession();
|
||||||
const parsed = submitRsvpSchema.safeParse(rawPayload);
|
const parsed = rsvpSubmissionSchema.safeParse(rawPayload);
|
||||||
|
if (!parsed.success)
|
||||||
if (!parsed.success) {
|
return {
|
||||||
return { error: "Ungültige Eingabedaten." };
|
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.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
const kitaId = session.user.kitaId;
|
||||||
if (!session.user.familyId) {
|
const familyId = session.user.familyId;
|
||||||
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
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(
|
export async function getEventParticipants(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { NextResponse } from "next/server";
|
|||||||
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { visibleAnnouncementAudiences } from "@/lib/announcement-access";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ export async function GET(
|
|||||||
id,
|
id,
|
||||||
announcement: {
|
announcement: {
|
||||||
kitaId: user.kitaId,
|
kitaId: user.kitaId,
|
||||||
|
audience: { in: visibleAnnouncementAudiences(user) },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -56,7 +58,7 @@ export async function GET(
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": attachment.fileType,
|
"Content-Type": attachment.fileType,
|
||||||
"Content-Disposition": contentDisposition(attachment.fileName),
|
"Content-Disposition": contentDisposition(attachment.fileName),
|
||||||
"Cache-Control": "private, max-age=300",
|
"Cache-Control": "private, no-store",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import {
|
import {
|
||||||
EventParticipationStatus,
|
EventParticipationStatus,
|
||||||
|
RsvpMode,
|
||||||
TerminStatus,
|
TerminStatus,
|
||||||
TerminType,
|
TerminType,
|
||||||
} from "@prisma/client";
|
} from "@prisma/client";
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { suggestedRsvpMode } from "@/lib/rsvp";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -48,10 +50,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import { createTerminAdmin, updateTerminAdmin } from "../../kalender/actions";
|
||||||
createTerminAdmin,
|
|
||||||
updateTerminAdmin,
|
|
||||||
} from "../../kalender/actions";
|
|
||||||
|
|
||||||
type AdminTerminDto = {
|
type AdminTerminDto = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -64,6 +63,12 @@ type AdminTerminDto = {
|
|||||||
allDay: boolean;
|
allDay: boolean;
|
||||||
mitbringselListEnabled: boolean;
|
mitbringselListEnabled: boolean;
|
||||||
requiresRsvp: boolean;
|
requiresRsvp: boolean;
|
||||||
|
rsvpMode: RsvpMode;
|
||||||
|
familyParticipations: {
|
||||||
|
familyId: string;
|
||||||
|
status: EventParticipationStatus;
|
||||||
|
adultCount: number;
|
||||||
|
}[];
|
||||||
rsvpDeadline: string | null;
|
rsvpDeadline: string | null;
|
||||||
participantInfo: string | null;
|
participantInfo: string | null;
|
||||||
participations: {
|
participations: {
|
||||||
@@ -81,9 +86,11 @@ type ChildDto = {
|
|||||||
export function AdminCalendarManager({
|
export function AdminCalendarManager({
|
||||||
termine,
|
termine,
|
||||||
childRows,
|
childRows,
|
||||||
|
familyRows,
|
||||||
}: {
|
}: {
|
||||||
termine: AdminTerminDto[];
|
termine: AdminTerminDto[];
|
||||||
childRows: ChildDto[];
|
childRows: ChildDto[];
|
||||||
|
familyRows: { id: string; name: string }[];
|
||||||
}) {
|
}) {
|
||||||
const rsvpCount = termine.filter((termin) => termin.requiresRsvp).length;
|
const rsvpCount = termine.filter((termin) => termin.requiresRsvp).length;
|
||||||
const pendingCount = termine.filter(
|
const pendingCount = termine.filter(
|
||||||
@@ -104,8 +111,8 @@ export function AdminCalendarManager({
|
|||||||
</em>
|
</em>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
<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
|
Termine verwalten, RSVP aktivieren und den Rücklauf pro Familie oder
|
||||||
verfolgen.
|
Kind verfolgen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-wrap gap-2">
|
<div className="flex shrink-0 flex-wrap gap-2">
|
||||||
@@ -134,7 +141,7 @@ export function AdminCalendarManager({
|
|||||||
Noch keine Termine
|
Noch keine Termine
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -145,6 +152,7 @@ export function AdminCalendarManager({
|
|||||||
key={termin.id}
|
key={termin.id}
|
||||||
termin={termin}
|
termin={termin}
|
||||||
childRows={childRows}
|
childRows={childRows}
|
||||||
|
familyRows={familyRows}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -156,11 +164,25 @@ export function AdminCalendarManager({
|
|||||||
function AdminTerminCard({
|
function AdminTerminCard({
|
||||||
termin,
|
termin,
|
||||||
childRows,
|
childRows,
|
||||||
|
familyRows,
|
||||||
}: {
|
}: {
|
||||||
termin: AdminTerminDto;
|
termin: AdminTerminDto;
|
||||||
childRows: ChildDto[];
|
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 (
|
return (
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
@@ -175,7 +197,9 @@ function AdminTerminCard({
|
|||||||
{getStatusLabel(termin.status)}
|
{getStatusLabel(termin.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{termin.requiresRsvp && (
|
{termin.requiresRsvp && (
|
||||||
<Badge variant="warning">RSVP aktiv</Badge>
|
<Badge variant="warning">
|
||||||
|
Antwort pro {isFamily ? "Familie" : "Kind"}
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-lg">{termin.title}</CardTitle>
|
<CardTitle className="text-lg">{termin.title}</CardTitle>
|
||||||
@@ -217,14 +241,19 @@ function AdminTerminCard({
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{stats.attending} Zugesagt / {stats.notAttending} Abgesagt /{" "}
|
{stats.attending} Zugesagt / {stats.notAttending} Abgesagt /{" "}
|
||||||
{stats.pending} Ausstehend
|
{stats.pending} Ausstehend{" "}
|
||||||
|
{isFamily ? `· ${stats.adultCount} Erwachsene` : ""}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||||
Frist:{" "}
|
Frist:{" "}
|
||||||
{termin.rsvpDeadline
|
{termin.rsvpDeadline
|
||||||
? format(new Date(termin.rsvpDeadline), "dd.MM.yyyy HH:mm", {
|
? format(
|
||||||
locale: de,
|
new Date(termin.rsvpDeadline),
|
||||||
})
|
"dd.MM.yyyy HH:mm",
|
||||||
|
{
|
||||||
|
locale: de,
|
||||||
|
},
|
||||||
|
)
|
||||||
: "bis Terminbeginn"}
|
: "bis Terminbeginn"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,18 +270,24 @@ function AdminTerminCard({
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Kind</TableHead>
|
<TableHead>{isFamily ? "Familie" : "Kind"}</TableHead>
|
||||||
<TableHead>Familie</TableHead>
|
<TableHead>{isFamily ? "Erwachsene" : "Familie"}</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{childRows.map((child) => {
|
{rows.map((child) => {
|
||||||
const status = stats.statusByChildId.get(child.id) ?? null;
|
const status = stats.statusByChildId.get(child.id) ?? null;
|
||||||
return (
|
return (
|
||||||
<TableRow key={child.id}>
|
<TableRow key={child.id}>
|
||||||
<TableCell className="font-medium">{child.name}</TableCell>
|
<TableCell className="font-medium">
|
||||||
<TableCell>{child.familyName}</TableCell>
|
{child.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{isFamily
|
||||||
|
? (stats.adultCounts.get(child.id) ?? "–")
|
||||||
|
: child.familyName}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<RsvpStatusBadge status={status} />
|
<RsvpStatusBadge status={status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -283,6 +318,13 @@ function TerminFormDialog({
|
|||||||
const [requiresRsvp, setRequiresRsvp] = useState(
|
const [requiresRsvp, setRequiresRsvp] = useState(
|
||||||
termin?.requiresRsvp ?? false,
|
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();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
function handleAction(formData: FormData) {
|
function handleAction(formData: FormData) {
|
||||||
@@ -300,6 +342,7 @@ function TerminFormDialog({
|
|||||||
allDay: formData.get("allDay") === "on",
|
allDay: formData.get("allDay") === "on",
|
||||||
mitbringselListEnabled: formData.get("mitbringselListEnabled") === "on",
|
mitbringselListEnabled: formData.get("mitbringselListEnabled") === "on",
|
||||||
requiresRsvp: hasRsvp,
|
requiresRsvp: hasRsvp,
|
||||||
|
rsvpMode,
|
||||||
rsvpDeadline:
|
rsvpDeadline:
|
||||||
hasRsvp && deadlineRaw ? new Date(deadlineRaw).toISOString() : null,
|
hasRsvp && deadlineRaw ? new Date(deadlineRaw).toISOString() : null,
|
||||||
participantInfo: String(formData.get("participantInfo") ?? ""),
|
participantInfo: String(formData.get("participantInfo") ?? ""),
|
||||||
@@ -330,6 +373,10 @@ function TerminFormDialog({
|
|||||||
setOpen(nextOpen);
|
setOpen(nextOpen);
|
||||||
if (nextOpen) {
|
if (nextOpen) {
|
||||||
setRequiresRsvp(termin?.requiresRsvp ?? false);
|
setRequiresRsvp(termin?.requiresRsvp ?? false);
|
||||||
|
setRsvpMode(
|
||||||
|
termin?.rsvpMode ?? suggestedRsvpMode(TerminType.KITA_FEST),
|
||||||
|
);
|
||||||
|
setModeTouched(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -380,6 +427,12 @@ function TerminFormDialog({
|
|||||||
<select
|
<select
|
||||||
id={`${mode}-type`}
|
id={`${mode}-type`}
|
||||||
name="type"
|
name="type"
|
||||||
|
onChange={(event) => {
|
||||||
|
if (!termin && !modeLocked && !modeTouched)
|
||||||
|
setRsvpMode(
|
||||||
|
suggestedRsvpMode(event.target.value as TerminType),
|
||||||
|
);
|
||||||
|
}}
|
||||||
defaultValue={termin?.type ?? TerminType.KITA_FEST}
|
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)]"
|
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
|
required
|
||||||
@@ -453,9 +506,33 @@ function TerminFormDialog({
|
|||||||
{requiresRsvp && (
|
{requiresRsvp && (
|
||||||
<div className="grid gap-4 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4">
|
<div className="grid gap-4 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor={`${mode}-rsvpDeadline`}>
|
<Label htmlFor={`${mode}-rsvpMode`}>Antwortart</Label>
|
||||||
Rückmeldefrist
|
<select
|
||||||
</Label>
|
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
|
<Input
|
||||||
id={`${mode}-rsvpDeadline`}
|
id={`${mode}-rsvpDeadline`}
|
||||||
name="rsvpDeadline"
|
name="rsvpDeadline"
|
||||||
@@ -532,28 +609,34 @@ function RsvpStatusBadge({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStats(termin: AdminTerminDto, children: ChildDto[]) {
|
function getStats(termin: AdminTerminDto, rows: ChildDto[]) {
|
||||||
const statusByChildId = new Map(
|
const isFamily = termin.rsvpMode === RsvpMode.FAMILY;
|
||||||
termin.participations.map((participation) => [
|
const statusByChildId = new Map<string, EventParticipationStatus>(
|
||||||
participation.childId,
|
isFamily
|
||||||
participation.status,
|
? 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 attending = 0;
|
||||||
let notAttending = 0;
|
let notAttending = 0;
|
||||||
|
let adultCount = 0;
|
||||||
children.forEach((child) => {
|
for (const row of rows) {
|
||||||
const status = statusByChildId.get(child.id);
|
const status = statusByChildId.get(row.id);
|
||||||
if (status === EventParticipationStatus.ATTENDING) attending += 1;
|
if (status === EventParticipationStatus.ATTENDING) {
|
||||||
|
attending += 1;
|
||||||
|
adultCount += adultCounts.get(row.id) ?? 0;
|
||||||
|
}
|
||||||
if (status === EventParticipationStatus.NOT_ATTENDING) notAttending += 1;
|
if (status === EventParticipationStatus.NOT_ATTENDING) notAttending += 1;
|
||||||
});
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
attending,
|
attending,
|
||||||
notAttending,
|
notAttending,
|
||||||
pending: Math.max(children.length - attending - notAttending, 0),
|
pending: rows.length - attending - notAttending,
|
||||||
statusByChildId,
|
statusByChildId,
|
||||||
|
adultCounts,
|
||||||
|
adultCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default async function AdminKalenderPage() {
|
|||||||
return <div className="p-8">Kein Mandant zugeordnet.</div>;
|
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({
|
prisma.termin.findMany({
|
||||||
where: { kitaId },
|
where: { kitaId },
|
||||||
select: {
|
select: {
|
||||||
@@ -28,6 +28,10 @@ export default async function AdminKalenderPage() {
|
|||||||
allDay: true,
|
allDay: true,
|
||||||
mitbringselListEnabled: true,
|
mitbringselListEnabled: true,
|
||||||
requiresRsvp: true,
|
requiresRsvp: true,
|
||||||
|
rsvpMode: true,
|
||||||
|
familyParticipations: {
|
||||||
|
select: { familyId: true, status: true, adultCount: true },
|
||||||
|
},
|
||||||
rsvpDeadline: true,
|
rsvpDeadline: true,
|
||||||
participantInfo: true,
|
participantInfo: true,
|
||||||
participations: {
|
participations: {
|
||||||
@@ -60,6 +64,11 @@ export default async function AdminKalenderPage() {
|
|||||||
{ firstName: "asc" },
|
{ firstName: "asc" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
prisma.family.findMany({
|
||||||
|
where: { kitaId },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -70,6 +79,7 @@ export default async function AdminKalenderPage() {
|
|||||||
endDate: termin.endDate.toISOString(),
|
endDate: termin.endDate.toISOString(),
|
||||||
rsvpDeadline: termin.rsvpDeadline?.toISOString() ?? null,
|
rsvpDeadline: termin.rsvpDeadline?.toISOString() ?? null,
|
||||||
}))}
|
}))}
|
||||||
|
familyRows={families}
|
||||||
childRows={children.map((child) => ({
|
childRows={children.map((child) => ({
|
||||||
id: child.id,
|
id: child.id,
|
||||||
name: `${child.firstName} ${child.lastName}`,
|
name: `${child.firstName} ${child.lastName}`,
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ export function NewsForm() {
|
|||||||
rows={10}
|
rows={10}
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(event) => setContent(event.target.value)}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-[var(--ink-muted)]">
|
<p className="text-xs text-[var(--ink-muted)]">
|
||||||
@@ -110,21 +112,23 @@ export function NewsForm() {
|
|||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sendEmail && (
|
<div className="mt-4 grid gap-2">
|
||||||
<div className="mt-4 grid gap-2">
|
<Label htmlFor="news-audience">Empfängergruppe</Label>
|
||||||
<Label>Empfänger</Label>
|
<p className="text-xs text-[var(--ink-soft)]">
|
||||||
<Select name="audience" defaultValue="ELTERN">
|
Bestimmt, wer die Meldung und Anhänge in der App sehen darf und
|
||||||
<SelectTrigger>
|
optional die E-Mail erhält.
|
||||||
<SelectValue placeholder="Empfänger auswählen" />
|
</p>
|
||||||
</SelectTrigger>
|
<Select name="audience" defaultValue="ELTERN">
|
||||||
<SelectContent>
|
<SelectTrigger id="news-audience">
|
||||||
<SelectItem value="ELTERN">Eltern</SelectItem>
|
<SelectValue placeholder="Empfänger auswählen" />
|
||||||
<SelectItem value="ERZIEHER">ErzieherInnen</SelectItem>
|
</SelectTrigger>
|
||||||
<SelectItem value="ALLE">Alle</SelectItem>
|
<SelectContent>
|
||||||
</SelectContent>
|
<SelectItem value="ELTERN">Eltern</SelectItem>
|
||||||
</Select>
|
<SelectItem value="ERZIEHER">ErzieherInnen</SelectItem>
|
||||||
</div>
|
<SelectItem value="ALLE">Eltern und ErzieherInnen</SelectItem>
|
||||||
)}
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import { announcementAudienceLabels } from "@/lib/announcement-access";
|
||||||
import { NewsForm } from "./news-form";
|
import { NewsForm } from "./news-form";
|
||||||
|
|
||||||
export const metadata = { title: "Schwarzes Brett · Kita-Planer" };
|
export const metadata = { title: "Schwarzes Brett · Kita-Planer" };
|
||||||
@@ -30,6 +31,7 @@ export default async function AdminNewsPage() {
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
|
audience: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
attachments: {
|
attachments: {
|
||||||
select: {
|
select: {
|
||||||
@@ -63,8 +65,8 @@ export default async function AdminNewsPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
<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
|
Veröffentliche verbindliche Ankündigungen für deine Kita. Anhänge
|
||||||
werden geschützt gespeichert und nur für eingeloggte Nutzer
|
werden geschützt gespeichert und nur an die gewählte Empfängergruppe
|
||||||
derselben Kita ausgeliefert.
|
deiner Kita ausgeliefert.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Badge variant="outline" className="mb-2">
|
||||||
|
{announcementAudienceLabels[announcement.audience]}
|
||||||
|
</Badge>
|
||||||
<p className="text-xs text-[var(--ink-muted)]">
|
<p className="text-xs text-[var(--ink-muted)]">
|
||||||
{format(announcement.createdAt, "dd.MM.yyyy HH:mm", {
|
{format(announcement.createdAt, "dd.MM.yyyy HH:mm", {
|
||||||
locale: de,
|
locale: de,
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import type { ComponentProps, FormEvent } from "react";
|
import type { ComponentProps, FormEvent } from "react";
|
||||||
import { useEffect, useMemo, useState, useTransition } 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 { Plus } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { suggestedRsvpMode } from "@/lib/rsvp";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -28,6 +29,11 @@ type AdminTerminFormState = {
|
|||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
allDay: boolean;
|
allDay: boolean;
|
||||||
|
requiresRsvp: boolean;
|
||||||
|
rsvpMode: RsvpMode;
|
||||||
|
rsvpDeadline: string;
|
||||||
|
participantInfo: string;
|
||||||
|
mitbringselListEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialFormState: AdminTerminFormState = {
|
const initialFormState: AdminTerminFormState = {
|
||||||
@@ -37,6 +43,11 @@ const initialFormState: AdminTerminFormState = {
|
|||||||
startDate: "",
|
startDate: "",
|
||||||
endDate: "",
|
endDate: "",
|
||||||
allDay: false,
|
allDay: false,
|
||||||
|
requiresRsvp: false,
|
||||||
|
rsvpMode: RsvpMode.FAMILY,
|
||||||
|
rsvpDeadline: "",
|
||||||
|
participantInfo: "",
|
||||||
|
mitbringselListEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
type AdminTerminModalProps = {
|
type AdminTerminModalProps = {
|
||||||
@@ -51,6 +62,12 @@ type AdminTerminModalProps = {
|
|||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
allDay: boolean;
|
allDay: boolean;
|
||||||
|
requiresRsvp: boolean;
|
||||||
|
rsvpMode: RsvpMode;
|
||||||
|
rsvpDeadline: Date | null;
|
||||||
|
participantInfo: string | null;
|
||||||
|
mitbringselListEnabled: boolean;
|
||||||
|
hasRsvpResponses: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,10 +78,12 @@ export function AdminTerminModal({
|
|||||||
existingTermin,
|
existingTermin,
|
||||||
}: AdminTerminModalProps) {
|
}: AdminTerminModalProps) {
|
||||||
const [open, setOpen] = useState(false);
|
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 [isPending, startTransition] = useTransition();
|
||||||
const minDateTime = useMemo(() => getLocalDateTimeInputValue(new Date()), []);
|
const minDateTime = useMemo(() => getLocalDateTimeInputValue(new Date()), []);
|
||||||
const minEndDateTime = formState.startDate || minDateTime;
|
|
||||||
|
|
||||||
// Initialize and reset form fields when modal opens
|
// Initialize and reset form fields when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -78,10 +97,18 @@ export function AdminTerminModal({
|
|||||||
startDate: getLocalDateTimeInputValue(existingTermin.startDate),
|
startDate: getLocalDateTimeInputValue(existingTermin.startDate),
|
||||||
endDate: getLocalDateTimeInputValue(existingTermin.endDate),
|
endDate: getLocalDateTimeInputValue(existingTermin.endDate),
|
||||||
allDay: existingTermin.allDay,
|
allDay: existingTermin.allDay,
|
||||||
|
requiresRsvp: existingTermin.requiresRsvp,
|
||||||
|
rsvpMode: existingTermin.rsvpMode,
|
||||||
|
rsvpDeadline: existingTermin.rsvpDeadline
|
||||||
|
? getLocalDateTimeInputValue(existingTermin.rsvpDeadline)
|
||||||
|
: "",
|
||||||
|
participantInfo: existingTermin.participantInfo ?? "",
|
||||||
|
mitbringselListEnabled: existingTermin.mitbringselListEnabled,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setFormState(initialFormState);
|
setFormState(initialFormState);
|
||||||
}
|
}
|
||||||
|
setModeTouched(false);
|
||||||
}
|
}
|
||||||
}, [open, existingTermin]);
|
}, [open, existingTermin]);
|
||||||
|
|
||||||
@@ -91,7 +118,14 @@ export function AdminTerminModal({
|
|||||||
) => {
|
) => {
|
||||||
setFormState((current) => {
|
setFormState((current) => {
|
||||||
const next = { ...current, [key]: value };
|
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;
|
next.endDate = value;
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
@@ -122,6 +156,13 @@ export function AdminTerminModal({
|
|||||||
startDate: new Date(formState.startDate).toISOString(),
|
startDate: new Date(formState.startDate).toISOString(),
|
||||||
endDate: new Date(formState.endDate).toISOString(),
|
endDate: new Date(formState.endDate).toISOString(),
|
||||||
allDay: formState.allDay,
|
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 () => {
|
startTransition(async () => {
|
||||||
@@ -138,7 +179,7 @@ export function AdminTerminModal({
|
|||||||
toast.success(
|
toast.success(
|
||||||
existingTermin
|
existingTermin
|
||||||
? "Termin wurde erfolgreich aktualisiert!"
|
? "Termin wurde erfolgreich aktualisiert!"
|
||||||
: "Termin wurde direkt angelegt!"
|
: "Termin wurde direkt angelegt!",
|
||||||
);
|
);
|
||||||
if (!existingTermin) {
|
if (!existingTermin) {
|
||||||
setFormState(initialFormState);
|
setFormState(initialFormState);
|
||||||
@@ -156,11 +197,13 @@ export function AdminTerminModal({
|
|||||||
{triggerLabel}
|
{triggerLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent className="max-h-[85vh] overflow-y-auto">
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{existingTermin ? "Termin bearbeiten (Admin)" : "Termin anlegen (Admin)"}
|
{existingTermin
|
||||||
|
? "Termin bearbeiten (Admin)"
|
||||||
|
: "Termin anlegen (Admin)"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{existingTermin
|
{existingTermin
|
||||||
@@ -189,7 +232,9 @@ export function AdminTerminModal({
|
|||||||
name="description"
|
name="description"
|
||||||
placeholder="Details zum Termin..."
|
placeholder="Details zum Termin..."
|
||||||
value={formState.description}
|
value={formState.description}
|
||||||
onChange={(event) => updateField("description", event.target.value)}
|
onChange={(event) =>
|
||||||
|
updateField("description", event.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -200,18 +245,28 @@ export function AdminTerminModal({
|
|||||||
name="type"
|
name="type"
|
||||||
value={formState.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)]"
|
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
|
required
|
||||||
>
|
>
|
||||||
<option value={TerminType.KITA_FEST}>Kita-Fest</option>
|
<option value={TerminType.KITA_FEST}>Kita-Fest</option>
|
||||||
<option value={TerminType.SCHLIESSTAG}>Schließtag</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.MITMACH_TAG}>Mitmach-Tag</option>
|
||||||
<option value={TerminType.ELTERNABEND}>Elternabend</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.ELTERNCAFE}>Elterncafe</option>
|
||||||
<option value={TerminType.GEBURTSTAG_INTERN}>Geburtstag (Intern)</option>
|
<option value={TerminType.GEBURTSTAG_INTERN}>
|
||||||
<option value={TerminType.GEBURTSTAG_EXTERN}>Raumanfrage (Extern)</option>
|
Geburtstag (Intern)
|
||||||
|
</option>
|
||||||
|
<option value={TerminType.GEBURTSTAG_EXTERN}>
|
||||||
|
Raumanfrage (Extern)
|
||||||
|
</option>
|
||||||
<option value={TerminType.SONSTIGES}>Sonstiges</option>
|
<option value={TerminType.SONSTIGES}>Sonstiges</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,7 +280,9 @@ export function AdminTerminModal({
|
|||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
min={existingTermin ? undefined : minDateTime}
|
min={existingTermin ? undefined : minDateTime}
|
||||||
value={formState.startDate}
|
value={formState.startDate}
|
||||||
onChange={(event) => updateField("startDate", event.target.value)}
|
onChange={(event) =>
|
||||||
|
updateField("startDate", event.target.value)
|
||||||
|
}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -235,14 +292,82 @@ export function AdminTerminModal({
|
|||||||
id="endDate"
|
id="endDate"
|
||||||
name="endDate"
|
name="endDate"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
min={formState.startDate || (existingTermin ? undefined : minDateTime)}
|
min={
|
||||||
|
formState.startDate ||
|
||||||
|
(existingTermin ? undefined : minDateTime)
|
||||||
|
}
|
||||||
value={formState.endDate}
|
value={formState.endDate}
|
||||||
onChange={(event) => updateField("endDate", event.target.value)}
|
onChange={(event) =>
|
||||||
|
updateField("endDate", event.target.value)
|
||||||
|
}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -250,7 +375,9 @@ export function AdminTerminModal({
|
|||||||
name="allDay"
|
name="allDay"
|
||||||
checked={formState.allDay}
|
checked={formState.allDay}
|
||||||
className="rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
|
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">
|
<Label htmlFor="allDay" className="font-normal cursor-pointer">
|
||||||
Ganztägiger Termin
|
Ganztägiger Termin
|
||||||
@@ -259,11 +386,19 @@ export function AdminTerminModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isPending}>
|
<Button type="submit" disabled={isPending}>
|
||||||
{isPending ? "Speichern..." : existingTermin ? "Änderungen speichern" : "Termin erstellen"}
|
{isPending
|
||||||
|
? "Speichern..."
|
||||||
|
: existingTermin
|
||||||
|
? "Änderungen speichern"
|
||||||
|
: "Termin erstellen"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
WashingMachine,
|
WashingMachine,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { RsvpDialog, RsvpForm } from "@/components/rsvp-form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -522,6 +523,11 @@ export function CalendarOverviewClient({
|
|||||||
</p>
|
</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 && (
|
{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="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">
|
<div className="mb-0.5 flex items-center gap-1.5 font-medium">
|
||||||
@@ -999,6 +1005,8 @@ function AgendaView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{item.kind === "termin" && item.rsvpEvent ? <RsvpDialog event={item.rsvpEvent} /> : null}
|
||||||
|
|
||||||
{/* Actions for admins */}
|
{/* Actions for admins */}
|
||||||
{isAdmin && item.kind === "termin" && (
|
{isAdmin && item.kind === "termin" && (
|
||||||
<div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
<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 type { ReactNode } from "react";
|
||||||
import { useState, useTransition } 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 { format } from "date-fns";
|
||||||
import { de } from "date-fns/locale";
|
import { de } from "date-fns/locale";
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +20,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { RsvpForm } from "@/components/rsvp-form";
|
||||||
|
import type { RsvpEventDto } from "@/lib/rsvp";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -44,6 +51,10 @@ export type TerminListItemDto = {
|
|||||||
allDay: boolean;
|
allDay: boolean;
|
||||||
mitbringselListEnabled: boolean;
|
mitbringselListEnabled: boolean;
|
||||||
requiresRsvp: boolean;
|
requiresRsvp: boolean;
|
||||||
|
rsvpMode: RsvpMode;
|
||||||
|
rsvpDeadline: Date | null;
|
||||||
|
hasRsvpResponses: boolean;
|
||||||
|
rsvpEvent?: RsvpEventDto;
|
||||||
participantInfo: string | null;
|
participantInfo: string | null;
|
||||||
showParticipantInfo: boolean;
|
showParticipantInfo: boolean;
|
||||||
mitbringselItems?: {
|
mitbringselItems?: {
|
||||||
@@ -88,7 +99,10 @@ export type NotdienstSwapOptionDto = {
|
|||||||
familyName: string;
|
familyName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto | NotdienstCalendarItemDto;
|
export type CalendarListItemDto =
|
||||||
|
| TerminListItemDto
|
||||||
|
| DutyCalendarItemDto
|
||||||
|
| NotdienstCalendarItemDto;
|
||||||
|
|
||||||
export function TerminList({
|
export function TerminList({
|
||||||
termine,
|
termine,
|
||||||
@@ -116,7 +130,10 @@ export function TerminList({
|
|||||||
</p>
|
</p>
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<AdminTerminModal triggerLabel="+ Termin anlegen" triggerClassName="" />
|
<AdminTerminModal
|
||||||
|
triggerLabel="+ Termin anlegen"
|
||||||
|
triggerClassName=""
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TerminRequestModal
|
<TerminRequestModal
|
||||||
triggerLabel="+ Termin anlegen"
|
triggerLabel="+ Termin anlegen"
|
||||||
@@ -141,14 +158,19 @@ export function TerminList({
|
|||||||
{group.label}
|
{group.label}
|
||||||
</h2>
|
</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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Card className="overflow-hidden p-0">
|
<Card className="overflow-hidden p-0">
|
||||||
<div className="divide-y divide-[var(--hairline-soft)]">
|
<div className="divide-y divide-[var(--hairline-soft)]">
|
||||||
{group.items.map((item) =>
|
{group.items.map((item) =>
|
||||||
item.kind === "duty" ? (
|
item.kind === "duty" ? (
|
||||||
<DutyRow key={`duty-${item.id}`} duty={item} isAdmin={isAdmin} />
|
<DutyRow
|
||||||
|
key={`duty-${item.id}`}
|
||||||
|
duty={item}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
/>
|
||||||
) : item.kind === "notdienst" ? (
|
) : item.kind === "notdienst" ? (
|
||||||
<NotdienstRow
|
<NotdienstRow
|
||||||
key={`notdienst-${item.id}`}
|
key={`notdienst-${item.id}`}
|
||||||
@@ -157,7 +179,12 @@ export function TerminList({
|
|||||||
notdienstSwapOptions={notdienstSwapOptions}
|
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>
|
</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 color = duty.isOwn ? "oklch(0.60 0.16 160)" : "oklch(0.75 0.08 160)";
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
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="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} />
|
<DateTile date={duty.startDate} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
|
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
|
||||||
@@ -207,7 +243,10 @@ function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolea
|
|||||||
</Button>
|
</Button>
|
||||||
{menuOpen && (
|
{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">
|
<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
|
<a
|
||||||
href="/dashboard/admin/dienste"
|
href="/dashboard/admin/dienste"
|
||||||
@@ -257,7 +296,10 @@ function TerminRow({
|
|||||||
<>
|
<>
|
||||||
<Dialog>
|
<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="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} />
|
<DateTile date={termin.startDate} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -276,7 +318,9 @@ function TerminRow({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 relative">
|
<div className="flex items-center gap-2 relative">
|
||||||
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
|
<CategoryPill color={color}>
|
||||||
|
{getTypeLabel(termin.type)}
|
||||||
|
</CategoryPill>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm">
|
||||||
Details
|
Details
|
||||||
@@ -297,7 +341,10 @@ function TerminRow({
|
|||||||
</Button>
|
</Button>
|
||||||
{menuOpen && (
|
{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">
|
<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
|
<AdminTerminModal
|
||||||
existingTermin={termin}
|
existingTermin={termin}
|
||||||
@@ -342,7 +389,9 @@ function TerminRow({
|
|||||||
|
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
|
<CategoryPill color={color}>
|
||||||
|
{getTypeLabel(termin.type)}
|
||||||
|
</CategoryPill>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{termin.description ? (
|
{termin.description ? (
|
||||||
@@ -355,6 +404,16 @@ function TerminRow({
|
|||||||
</p>
|
</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 && (
|
{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="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">
|
<div className="mb-1 flex items-center gap-2 font-medium">
|
||||||
@@ -385,8 +444,9 @@ function TerminRow({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
|
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Möchtest du den Termin “{termin.title}” wirklich löschen? Alle
|
Möchtest du den Termin “{termin.title}” wirklich
|
||||||
zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren.
|
löschen? Alle zugehörigen Teilnahmen und Mitbringsel-Einträge
|
||||||
|
gehen verloren.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<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)]"
|
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 }}
|
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}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupByMonth(items: CalendarListItemDto[]) {
|
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) => {
|
items.forEach((item) => {
|
||||||
const key = format(item.startDate, "yyyy-MM");
|
const key = format(item.startDate, "yyyy-MM");
|
||||||
@@ -559,7 +625,8 @@ export function NotdienstSwapDialog({
|
|||||||
<DialogTitle>Notdienst tauschen</DialogTitle>
|
<DialogTitle>Notdienst tauschen</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Frage Familie {target.familyName}, ob ihr den Notdienst am{" "}
|
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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -575,11 +642,14 @@ export function NotdienstSwapDialog({
|
|||||||
onChange={(event) => setOfferedAssignmentId(event.target.value)}
|
onChange={(event) => setOfferedAssignmentId(event.target.value)}
|
||||||
>
|
>
|
||||||
{selectableOptions.length === 0 ? (
|
{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) => (
|
selectableOptions.map((option) => (
|
||||||
<option key={option.id} value={option.id}>
|
<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>
|
</option>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -603,7 +673,11 @@ export function NotdienstSwapDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" onClick={() => setOpen(false)} disabled={isPending}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -632,7 +706,10 @@ function NotdienstRow({
|
|||||||
|
|
||||||
return (
|
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="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} />
|
<DateTile date={notdienst.startDate} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
|
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
|
||||||
@@ -640,7 +717,8 @@ function NotdienstRow({
|
|||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
|
<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]" />
|
<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="inline-flex items-center gap-1">
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-[var(--ink-muted)]" />
|
<span className="h-1.5 w-1.5 rounded-full bg-[var(--ink-muted)]" />
|
||||||
Kind: {notdienst.childName}
|
Kind: {notdienst.childName}
|
||||||
@@ -666,7 +744,10 @@ function NotdienstRow({
|
|||||||
</Button>
|
</Button>
|
||||||
{menuOpen && (
|
{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">
|
<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
|
<a
|
||||||
href={`/dashboard/notdienst/plan?year=${notdienst.planYear}&month=${notdienst.planMonth}`}
|
href={`/dashboard/notdienst/plan?year=${notdienst.planYear}&month=${notdienst.planMonth}`}
|
||||||
@@ -684,7 +765,11 @@ function NotdienstRow({
|
|||||||
target={notdienst}
|
target={notdienst}
|
||||||
options={notdienstSwapOptions}
|
options={notdienstSwapOptions}
|
||||||
trigger={
|
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
|
Tausch anfragen
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
NotdienstAlertStatus,
|
NotdienstAlertStatus,
|
||||||
NotdienstPlanStatus,
|
NotdienstPlanStatus,
|
||||||
NotdienstSwapStatus,
|
NotdienstSwapStatus,
|
||||||
|
RsvpMode,
|
||||||
TerminStatus,
|
TerminStatus,
|
||||||
TerminType,
|
TerminType,
|
||||||
UserRole,
|
UserRole,
|
||||||
@@ -18,6 +19,7 @@ import { NotdienstSwapEmail } from "@/emails/NotdienstSwapEmail";
|
|||||||
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
||||||
import { sendAppEmail } from "@/lib/mail";
|
import { sendAppEmail } from "@/lib/mail";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { lockEvent } from "@/lib/event-lock";
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// /dashboard/kalender · Server Actions
|
// /dashboard/kalender · Server Actions
|
||||||
@@ -30,8 +32,9 @@ const terminSchema = z.object({
|
|||||||
startDate: z.string().datetime(),
|
startDate: z.string().datetime(),
|
||||||
endDate: z.string().datetime(),
|
endDate: z.string().datetime(),
|
||||||
allDay: z.boolean().default(false),
|
allDay: z.boolean().default(false),
|
||||||
mitbringselListEnabled: z.boolean().default(false),
|
mitbringselListEnabled: z.boolean().optional(),
|
||||||
requiresRsvp: z.boolean().default(false),
|
requiresRsvp: z.boolean().optional(),
|
||||||
|
rsvpMode: z.nativeEnum(RsvpMode).optional(),
|
||||||
rsvpDeadline: z.string().datetime().nullable().optional(),
|
rsvpDeadline: z.string().datetime().nullable().optional(),
|
||||||
participantInfo: z.string().trim().max(1000).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 startDate = new Date(data.startDate);
|
||||||
const endDate = new Date(data.endDate);
|
const endDate = new Date(data.endDate);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
now.setSeconds(0, 0);
|
now.setSeconds(0, 0);
|
||||||
|
|
||||||
if (startDate < now) {
|
if (!allowPast && startDate < now) {
|
||||||
return {
|
return {
|
||||||
error: "Der Startzeitpunkt darf nicht in der Vergangenheit liegen.",
|
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." };
|
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 {
|
return {
|
||||||
data: {
|
data: {
|
||||||
title: data.title,
|
title: data.title,
|
||||||
@@ -298,8 +314,9 @@ function buildTerminData(data: z.infer<typeof terminSchema>) {
|
|||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
allDay: data.allDay,
|
allDay: data.allDay,
|
||||||
mitbringselListEnabled: data.mitbringselListEnabled,
|
mitbringselListEnabled: data.mitbringselListEnabled ?? false,
|
||||||
requiresRsvp: data.requiresRsvp,
|
requiresRsvp: data.requiresRsvp ?? false,
|
||||||
|
rsvpMode: data.rsvpMode ?? RsvpMode.CHILD,
|
||||||
rsvpDeadline:
|
rsvpDeadline:
|
||||||
data.requiresRsvp && data.rsvpDeadline
|
data.requiresRsvp && data.rsvpDeadline
|
||||||
? new Date(data.rsvpDeadline)
|
? new Date(data.rsvpDeadline)
|
||||||
@@ -852,6 +869,7 @@ export async function createTerminAdmin(rawPayload: unknown) {
|
|||||||
|
|
||||||
revalidatePath("/dashboard/kalender");
|
revalidatePath("/dashboard/kalender");
|
||||||
revalidatePath("/dashboard/admin/kalender");
|
revalidatePath("/dashboard/admin/kalender");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Fehler beim Erstellen des Termins:", 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 session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||||
const kitaId = session.user.kitaId;
|
const kitaId = session.user.kitaId;
|
||||||
|
|
||||||
@@ -872,19 +893,50 @@ export async function updateTerminAdmin(terminId: string, rawPayload: unknown) {
|
|||||||
return { error: "Ungültige Eingabedaten." };
|
return { error: "Ungültige Eingabedaten." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const terminData = buildTerminData(parsed.data);
|
|
||||||
if ("error" in terminData) {
|
|
||||||
return { error: terminData.error };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.termin.update({
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
where: {
|
await lockEvent(tx, terminId, kitaId);
|
||||||
id: terminId,
|
const existing = await tx.termin.findFirst({
|
||||||
kitaId,
|
where: { id: terminId, kitaId },
|
||||||
},
|
include: {
|
||||||
data: terminData.data,
|
_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/kalender");
|
||||||
revalidatePath("/dashboard/admin/kalender");
|
revalidatePath("/dashboard/admin/kalender");
|
||||||
@@ -918,6 +970,7 @@ export async function approveTermin(terminId: string) {
|
|||||||
|
|
||||||
revalidatePath("/dashboard/kalender");
|
revalidatePath("/dashboard/kalender");
|
||||||
revalidatePath("/dashboard/admin/kalender");
|
revalidatePath("/dashboard/admin/kalender");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Fehler beim Bestätigen des Termins:", 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/kalender");
|
||||||
revalidatePath("/dashboard/admin/kalender");
|
revalidatePath("/dashboard/admin/kalender");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Fehler beim Ablehnen des Termins:", error);
|
console.error("Fehler beim Ablehnen des Termins:", error);
|
||||||
@@ -978,6 +1032,7 @@ export async function toggleMitbringselList(
|
|||||||
|
|
||||||
revalidatePath("/dashboard/kalender");
|
revalidatePath("/dashboard/kalender");
|
||||||
revalidatePath("/dashboard/admin/kalender");
|
revalidatePath("/dashboard/admin/kalender");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Fehler beim Umschalten der Mitbring-Liste:", error);
|
console.error("Fehler beim Umschalten der Mitbring-Liste:", error);
|
||||||
|
|||||||
@@ -9,9 +9,16 @@ import { HelpCircle } from "lucide-react";
|
|||||||
|
|
||||||
import { requireKitaSession } from "@/lib/auth-utils";
|
import { requireKitaSession } from "@/lib/auth-utils";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getRsvpEvents } from "@/lib/rsvp-events";
|
||||||
import { AdminTerminModal } from "./_components/admin-termin-modal";
|
import { AdminTerminModal } from "./_components/admin-termin-modal";
|
||||||
import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-anfragen";
|
import {
|
||||||
import { type CalendarListItemDto, type NotdienstSwapOptionDto } from "./_components/termin-list";
|
PendingAnfragen,
|
||||||
|
type PendingTerminDto,
|
||||||
|
} from "./_components/pending-anfragen";
|
||||||
|
import {
|
||||||
|
type CalendarListItemDto,
|
||||||
|
type NotdienstSwapOptionDto,
|
||||||
|
} from "./_components/termin-list";
|
||||||
import { TerminRequestModal } from "./_components/termin-request-modal";
|
import { TerminRequestModal } from "./_components/termin-request-modal";
|
||||||
import {
|
import {
|
||||||
CalendarOverviewClient,
|
CalendarOverviewClient,
|
||||||
@@ -36,50 +43,62 @@ export default async function KalenderPage({
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
|
||||||
const confirmedTermineRows = await prisma.termin.findMany({
|
const [rsvpEventRows, confirmedTermineRows] = await Promise.all([
|
||||||
where: {
|
getRsvpEvents(session),
|
||||||
kitaId: session.user.kitaId,
|
prisma.termin.findMany({
|
||||||
status: TerminStatus.CONFIRMED,
|
where: {
|
||||||
},
|
kitaId: session.user.kitaId,
|
||||||
select: {
|
status: TerminStatus.CONFIRMED,
|
||||||
id: true,
|
},
|
||||||
title: true,
|
select: {
|
||||||
description: true,
|
id: true,
|
||||||
type: true,
|
title: true,
|
||||||
status: true,
|
description: true,
|
||||||
startDate: true,
|
type: true,
|
||||||
endDate: true,
|
status: true,
|
||||||
allDay: true,
|
startDate: true,
|
||||||
mitbringselListEnabled: true,
|
endDate: true,
|
||||||
requiresRsvp: true,
|
allDay: true,
|
||||||
participantInfo: true,
|
mitbringselListEnabled: true,
|
||||||
mitbringselItems: {
|
requiresRsvp: true,
|
||||||
select: {
|
rsvpMode: true,
|
||||||
id: true,
|
rsvpDeadline: true,
|
||||||
userId: true,
|
_count: {
|
||||||
content: true,
|
select: { participations: true, familyParticipations: true },
|
||||||
user: {
|
},
|
||||||
select: {
|
familyParticipations: {
|
||||||
firstName: true,
|
where: { familyId: familyIdForParticipation },
|
||||||
lastName: true,
|
select: { status: true },
|
||||||
|
},
|
||||||
|
participantInfo: true,
|
||||||
|
mitbringselItems: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
content: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
participations: {
|
||||||
participations: {
|
where: {
|
||||||
where: {
|
child: {
|
||||||
child: {
|
familyId: familyIdForParticipation,
|
||||||
familyId: familyIdForParticipation,
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
childId: true,
|
||||||
|
status: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: {
|
|
||||||
childId: true,
|
|
||||||
status: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
orderBy: { startDate: "asc" },
|
||||||
orderBy: { startDate: "asc" },
|
}),
|
||||||
});
|
]);
|
||||||
|
|
||||||
const myPendingTermineRows = await prisma.termin.findMany({
|
const myPendingTermineRows = await prisma.termin.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -98,6 +117,13 @@ export default async function KalenderPage({
|
|||||||
allDay: true,
|
allDay: true,
|
||||||
mitbringselListEnabled: true,
|
mitbringselListEnabled: true,
|
||||||
requiresRsvp: true,
|
requiresRsvp: true,
|
||||||
|
rsvpMode: true,
|
||||||
|
rsvpDeadline: true,
|
||||||
|
_count: { select: { participations: true, familyParticipations: true } },
|
||||||
|
familyParticipations: {
|
||||||
|
where: { familyId: familyIdForParticipation },
|
||||||
|
select: { status: true },
|
||||||
|
},
|
||||||
participantInfo: true,
|
participantInfo: true,
|
||||||
mitbringselItems: {
|
mitbringselItems: {
|
||||||
select: {
|
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[] = [
|
const allUserTermine: CalendarListItemDto[] = [
|
||||||
...confirmedTermineRows.map((termin) => ({
|
...confirmedTermineRows.map(mapTermin),
|
||||||
...termin,
|
...myPendingTermineRows.map(mapTermin),
|
||||||
kind: "termin" as const,
|
|
||||||
showParticipantInfo:
|
|
||||||
isAdmin ||
|
|
||||||
termin.participations.some(
|
|
||||||
(participation) =>
|
|
||||||
participation.status === EventParticipationStatus.ATTENDING,
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
...myPendingTermineRows.map((termin) => ({
|
|
||||||
...termin,
|
|
||||||
kind: "termin" as const,
|
|
||||||
showParticipantInfo:
|
|
||||||
isAdmin ||
|
|
||||||
termin.participations.some(
|
|
||||||
(participation) =>
|
|
||||||
participation.status === EventParticipationStatus.ATTENDING,
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
...dutyAssignmentRows.map((assignment) => {
|
...dutyAssignmentRows.map((assignment) => {
|
||||||
const isOwn = assignment.familyId === familyIdForParticipation;
|
const isOwn = assignment.familyId === familyIdForParticipation;
|
||||||
return {
|
return {
|
||||||
@@ -316,7 +347,9 @@ export default async function KalenderPage({
|
|||||||
(request) => ({
|
(request) => ({
|
||||||
id: request.id,
|
id: request.id,
|
||||||
direction:
|
direction:
|
||||||
request.requesterFamilyId === session.user.familyId ? "outgoing" : "incoming",
|
request.requesterFamilyId === session.user.familyId
|
||||||
|
? "outgoing"
|
||||||
|
: "incoming",
|
||||||
targetDate: request.targetAssignment.date,
|
targetDate: request.targetAssignment.date,
|
||||||
targetFamilyName: request.targetAssignment.child.family.name,
|
targetFamilyName: request.targetAssignment.child.family.name,
|
||||||
targetChildName: `${request.targetAssignment.child.firstName} ${request.targetAssignment.child.lastName}`,
|
targetChildName: `${request.targetAssignment.child.firstName} ${request.targetAssignment.child.lastName}`,
|
||||||
@@ -367,9 +400,7 @@ export default async function KalenderPage({
|
|||||||
</p>
|
</p>
|
||||||
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||||
Was steht{" "}
|
Was steht{" "}
|
||||||
<em className="font-normal italic text-[var(--accent-deep)]">
|
<em className="font-normal italic text-[var(--accent-deep)]">an</em>
|
||||||
an
|
|
||||||
</em>
|
|
||||||
?
|
?
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
<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({
|
function TabBar({
|
||||||
currentTab,
|
currentTab,
|
||||||
overviewCount,
|
overviewCount,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { Bell, FileText, Megaphone } from "lucide-react";
|
import { Bell, FileText, Megaphone } from "lucide-react";
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ export type NewsTickerItemDto = {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
excerpt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
isUnread: boolean;
|
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 [selected, setSelected] = useState<NewsTickerItemDto | null>(null);
|
||||||
const [readIds, setReadIds] = useState<Set<string>>(new Set());
|
const [readIds, setReadIds] = useState<Set<string>>(new Set());
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAnnouncement(item: NewsTickerItemDto) {
|
function openAnnouncement(item: NewsTickerItemDto) {
|
||||||
setSelected(item);
|
setSelected(item);
|
||||||
if (!item.isUnread || readIds.has(item.id)) return;
|
if (!item.isUnread || readIds.has(item.id)) return;
|
||||||
|
|
||||||
setReadIds((current) => new Set(current).add(item.id));
|
|
||||||
startTransition(async () => {
|
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)]">
|
<Card className="mb-8 border-[var(--good)] bg-[var(--good-soft)]">
|
||||||
<CardHeader className="pb-3">
|
<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>
|
<div>
|
||||||
<CardTitle className="flex items-center gap-2 text-lg">
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
<Megaphone className="h-5 w-5 text-[var(--good)] [stroke-width:1.7]" />
|
<Megaphone className="h-5 w-5 text-[var(--good)] [stroke-width:1.7]" />
|
||||||
Schwarzes Brett
|
{archive ? "Mitteilungen" : "Neues aus der Kita"}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Die neuesten offiziellen Ankündigungen deiner Kita.
|
{archive
|
||||||
|
? "Veröffentlichte Ankündigungen deiner Kita."
|
||||||
|
: "Die neuesten offiziellen Ankündigungen deiner Kita."}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-3 lg:grid-cols-3">
|
<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) => {
|
{items.map((item) => {
|
||||||
const isUnread = item.isUnread && !readIds.has(item.id);
|
const isUnread = item.isUnread && !readIds.has(item.id);
|
||||||
return (
|
return (
|
||||||
@@ -88,15 +114,13 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
|
|||||||
<Bell className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
<Bell className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||||
{item.createdAt}
|
{item.createdAt}
|
||||||
</div>
|
</div>
|
||||||
{isUnread && (
|
{isUnread && <Badge variant="success">NEU</Badge>}
|
||||||
<Badge variant="success">NEU</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className="line-clamp-2 font-semibold leading-snug text-[var(--ink)]">
|
<h3 className="line-clamp-2 font-semibold leading-snug text-[var(--ink)]">
|
||||||
{item.title}
|
{item.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 line-clamp-3 text-sm leading-6 text-[var(--ink-soft)]">
|
<p className="mt-2 line-clamp-3 text-sm leading-6 text-[var(--ink-soft)]">
|
||||||
{item.content}
|
{item.excerpt}
|
||||||
</p>
|
</p>
|
||||||
{item.attachments.length > 0 && (
|
{item.attachments.length > 0 && (
|
||||||
<p className="mt-3 flex items-center gap-1 text-xs font-medium text-[var(--accent-deep)]">
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||||
{selected && (
|
{selected && (
|
||||||
<>
|
<>
|
||||||
@@ -137,7 +164,11 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="justify-start"
|
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]" />
|
<FileText className="h-4 w-4 [stroke-width:1.7]" />
|
||||||
{attachment.fileName}
|
{attachment.fileName}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import { getTargetMonthData } from "@/lib/date-utils";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { Stat } from "@/components/ui/stat";
|
import { Stat } from "@/components/ui/stat";
|
||||||
import { OnboardingDialog } from "@/components/OnboardingDialog";
|
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 { AbsenceCard, UpcomingAbsencesCard } from "./absence-card";
|
||||||
import { AlarmDashboardHints } from "./notdienst/alarm/dashboard-hints";
|
import { AlarmDashboardHints } from "./notdienst/alarm/dashboard-hints";
|
||||||
|
|
||||||
@@ -56,6 +60,8 @@ export default async function DashboardPage() {
|
|||||||
onboardingUser,
|
onboardingUser,
|
||||||
absenceChildren,
|
absenceChildren,
|
||||||
upcomingAbsences,
|
upcomingAbsences,
|
||||||
|
announcements,
|
||||||
|
rsvpEvents,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
prisma.family.count({
|
prisma.family.count({
|
||||||
where: { kitaId: session.user.kitaId },
|
where: { kitaId: session.user.kitaId },
|
||||||
@@ -143,6 +149,8 @@ export default async function DashboardPage() {
|
|||||||
take: 6,
|
take: 6,
|
||||||
})
|
})
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
|
getAnnouncements(session, { take: 3 }),
|
||||||
|
getRsvpEvents(session, { onlyOpen: true }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const canReportAbsences = session.user.role !== UserRole.ERZIEHER;
|
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>
|
<div>
|
||||||
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
|
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
|
||||||
ÜBERSICHT · {kita.name}
|
ÜBERSICHT · {kita.name}
|
||||||
@@ -195,13 +203,16 @@ export default async function DashboardPage() {
|
|||||||
siehst du auf einen Blick, was bei {kita.name} ansteht.
|
siehst du auf einen Blick, was bei {kita.name} ansteht.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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]" />
|
<Clock3 className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||||
{format(now, "EEE, d. MMM yyyy", { locale: de })}
|
{format(now, "EEE, d. MMM yyyy", { locale: de })}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<AlarmDashboardHints />
|
<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="grid grid-cols-[minmax(0,1fr)_360px] gap-5 max-[1100px]:grid-cols-1">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{session.user.role === UserRole.ERZIEHER && (
|
{session.user.role === UserRole.ERZIEHER && (
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
import { EventParticipationStatus } from "@prisma/client";
|
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { de } from "date-fns/locale";
|
import { de } from "date-fns/locale";
|
||||||
import { AlertTriangle, Check, X } from "lucide-react";
|
import type { RsvpEventDto } from "@/lib/rsvp";
|
||||||
import { toast } from "sonner";
|
import { RsvpDialog } from "@/components/rsvp-form";
|
||||||
|
|
||||||
import { submitEventRsvp } from "@/actions/rsvp";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -17,41 +12,18 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
|
|
||||||
export type DashboardRsvpEventDto = {
|
export function RsvpActionCard({ events }: { events: RsvpEventDto[] }) {
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
startDate: string;
|
|
||||||
rsvpDeadline: string | null;
|
|
||||||
children: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
status: EventParticipationStatus | null;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function RsvpActionCard({ events }: { events: DashboardRsvpEventDto[] }) {
|
|
||||||
if (events.length === 0) return null;
|
if (events.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="mb-8 border-[var(--warn)] bg-[var(--warn-soft)]">
|
<Card className="mb-5 border-[var(--warn)] bg-[var(--warn-soft)]">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-lg text-[var(--ink)]">
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
<AlertTriangle className="h-5 w-5 text-[var(--warn)] [stroke-width:1.7]" />
|
<AlertTriangle className="h-5 w-5 text-[var(--warn)]" />
|
||||||
Aktion erforderlich
|
Rückmeldung erforderlich
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-[var(--ink-soft)]">
|
<CardDescription>
|
||||||
Für diese Termine fehlt noch mindestens eine Rückmeldung deiner
|
Für diese Termine fehlt noch eine Antwort eurer Familie.
|
||||||
Familie.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-3">
|
<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"
|
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>
|
<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)]">
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||||
Rückmeldung bis{" "}
|
Rückmeldung bis{" "}
|
||||||
{format(
|
{format(
|
||||||
@@ -78,130 +50,3 @@ export function RsvpActionCard({ events }: { events: DashboardRsvpEventDto[] })
|
|||||||
</Card>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ const navItems = [
|
|||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
exact: true,
|
exact: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
section: "Heute",
|
||||||
|
href: "/dashboard/mitteilungen",
|
||||||
|
label: "Mitteilungen",
|
||||||
|
icon: Megaphone,
|
||||||
|
exact: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
section: "Heute",
|
section: "Heute",
|
||||||
href: "/dashboard/notdienst",
|
href: "/dashboard/notdienst",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user