feat: add parent announcements and family or child event responses

This commit is contained in:
t.indorf
2026-10-09 21:44:19 +02:00
parent 2d3b82eb80
commit 979f795341
29 changed files with 2031 additions and 472 deletions
+22 -21
View File
@@ -5,13 +5,17 @@ import { mkdir, rm, writeFile } from "fs/promises";
import path from "path";
import { createElement } from "react";
import { revalidatePath } from "next/cache";
import { Prisma, UserRole } from "@prisma/client";
import { AnnouncementAudience, UserRole } from "@prisma/client";
import { z } from "zod";
import { NewsEmail } from "@/emails/NewsEmail";
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma";
import {
announcementRecipientWhere,
visibleAnnouncementAudiences,
} from "@/lib/announcement-access";
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
const ALLOWED_TYPES = new Map([
@@ -55,25 +59,15 @@ function validateAttachment(file: File) {
return null;
}
async function getRecipients(kitaId: string, audience: z.infer<typeof audienceSchema>) {
const where: Prisma.UserWhereInput =
audience === "ELTERN"
? {
kitaId,
familyId: { not: null },
emailVerifiedAt: { not: null },
}
: audience === "ERZIEHER"
? {
kitaId,
role: UserRole.ERZIEHER,
emailVerifiedAt: { not: null },
}
: {
kitaId,
emailVerifiedAt: { not: null },
OR: [{ familyId: { not: null } }, { role: UserRole.ERZIEHER }],
};
async function getRecipients(
kitaId: string,
audience: z.infer<typeof audienceSchema>,
) {
const where = {
kitaId,
emailVerifiedAt: { not: null },
...announcementRecipientWhere(audience as AnnouncementAudience),
};
const users = await prisma.user.findMany({
where,
@@ -129,6 +123,7 @@ export async function createAnnouncement(formData: FormData) {
kitaId,
title: parsed.data.title,
content: parsed.data.content,
audience: parsed.data.audience,
authorId: session.user.id,
attachments: {
create: attachmentInputs.map((attachment) => ({
@@ -166,7 +161,10 @@ export async function createAnnouncement(formData: FormData) {
rm(path.join(uploadDir, attachment.id), { force: true }),
),
);
console.error("Ankündigungs-Anhang konnte nicht gespeichert werden:", error);
console.error(
"Ankündigungs-Anhang konnte nicht gespeichert werden:",
error,
);
return { error: "Anhang konnte nicht gespeichert werden." };
}
}
@@ -198,6 +196,7 @@ export async function createAnnouncement(formData: FormData) {
revalidatePath("/dashboard");
revalidatePath("/dashboard/admin/news");
revalidatePath("/dashboard/mitteilungen");
if (emailError) {
return {
@@ -216,6 +215,7 @@ export async function markAnnouncementRead(announcementId: string) {
where: {
id: announcementId,
kitaId: session.user.kitaId,
audience: { in: visibleAnnouncementAudiences(session.user) },
},
select: { id: true },
});
@@ -241,5 +241,6 @@ export async function markAnnouncementRead(announcementId: string) {
});
revalidatePath("/dashboard");
revalidatePath("/dashboard/mitteilungen");
return { success: true };
}
+114 -77
View File
@@ -3,20 +3,16 @@
import { revalidatePath } from "next/cache";
import {
EventParticipationStatus,
RsvpMode,
TerminStatus,
UserRole,
} from "@prisma/client";
import { z } from "zod";
import { rsvpSubmissionSchema } from "@/lib/rsvp";
import { lockEvent } from "@/lib/event-lock";
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
const submitRsvpSchema = z.object({
eventId: z.string().min(1),
childId: z.string().min(1),
status: z.nativeEnum(EventParticipationStatus),
});
export type EventParticipantDto = {
childId: string;
childName: string;
@@ -24,79 +20,120 @@ export type EventParticipantDto = {
status: EventParticipationStatus | null;
};
export async function submitEventRsvp(rawPayload: unknown) {
export async function submitEventRsvp(
rawPayload: unknown,
): Promise<{ error?: string; success?: boolean }> {
const session = await requireKitaSession();
const parsed = submitRsvpSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
const parsed = rsvpSubmissionSchema.safeParse(rawPayload);
if (!parsed.success)
return {
error:
"Bitte alle Antworten und bei Familienzusagen die Anzahl Erwachsener angeben.",
};
if (!session.user.familyId || session.user.role === UserRole.ERZIEHER) {
return {
error: "Für Rückmeldungen ist ein Elternkonto mit Haushalt erforderlich.",
};
}
if (!session.user.familyId) {
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
const kitaId = session.user.kitaId;
const familyId = session.user.familyId;
const payload = parsed.data;
try {
const result = await prisma.$transaction(async (tx) => {
await lockEvent(tx, payload.eventId, kitaId);
const event = await tx.termin.findFirst({
where: { id: payload.eventId, kitaId },
select: {
status: true,
requiresRsvp: true,
rsvpMode: true,
rsvpDeadline: true,
startDate: true,
kita: { select: { terminModuleEnabled: true } },
},
});
if (
!event?.kita.terminModuleEnabled ||
!event.requiresRsvp ||
event.status !== TerminStatus.CONFIRMED
) {
return { error: "Für diesen Termin ist keine Rückmeldung aktiv." };
}
if (event.rsvpMode !== payload.mode)
return {
error: "Die Antwortart wurde geändert. Bitte lade den Termin erneut.",
};
if (payload.mode === RsvpMode.FAMILY) {
const family = await tx.family.findFirst({
where: { id: familyId, kitaId },
select: { id: true },
});
if (!family) return { error: "Haushalt wurde nicht gefunden." };
if (
(event.rsvpDeadline ?? event.startDate) <= new Date() ||
event.startDate <= new Date()
) {
return { error: "Die Rückmeldefrist ist bereits abgelaufen." };
}
await tx.familyEventParticipation.upsert({
where: { eventId_familyId: { eventId: payload.eventId, familyId } },
create: {
kitaId,
eventId: payload.eventId,
familyId,
status: payload.status,
adultCount: payload.adultCount,
},
update: { status: payload.status, adultCount: payload.adultCount },
});
} else {
const children = await tx.child.findMany({
where: { kitaId, familyId, active: true },
select: { id: true },
});
const ids = new Set(children.map((child) => child.id));
if (
payload.responses.length !== ids.size ||
payload.responses.some((row) => !ids.has(row.childId))
) {
return {
error:
"Bitte antworte für alle eigenen aktiven Kinder. Lade den Termin bei Änderungen erneut.",
};
}
if (
(event.rsvpDeadline ?? event.startDate) <= new Date() ||
event.startDate <= new Date()
) {
return { error: "Die Rückmeldefrist ist bereits abgelaufen." };
}
for (const response of payload.responses) {
await tx.eventParticipation.upsert({
where: {
eventId_childId: {
eventId: payload.eventId,
childId: response.childId,
},
},
create: { kitaId, eventId: payload.eventId, ...response },
update: { status: response.status },
});
}
}
return { success: true };
});
if (result.error) return result;
revalidatePath("/dashboard");
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
return result;
} catch (error) {
console.error("Rückmeldung konnte nicht gespeichert werden:", error);
return {
error:
"Rückmeldung konnte nicht gespeichert werden. Bitte versuche es erneut.",
};
}
const { eventId, childId, status } = parsed.data;
const event = await prisma.termin.findFirst({
where: {
id: eventId,
kitaId: session.user.kitaId,
},
select: {
id: true,
status: true,
requiresRsvp: true,
rsvpDeadline: true,
startDate: true,
},
});
if (!event || !event.requiresRsvp || event.status !== TerminStatus.CONFIRMED) {
return { error: "Für diesen Termin ist keine Rückmeldung aktiv." };
}
const deadline = event.rsvpDeadline ?? event.startDate;
if (deadline < new Date()) {
return { error: "Die Rückmeldefrist ist bereits abgelaufen." };
}
const child = await prisma.child.findFirst({
where: {
id: childId,
kitaId: session.user.kitaId,
familyId: session.user.familyId,
active: true,
},
select: { id: true },
});
if (!child) {
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
}
await prisma.eventParticipation.upsert({
where: {
eventId_childId: {
eventId,
childId,
},
},
create: {
kitaId: session.user.kitaId,
eventId,
childId,
status,
},
update: {
status,
},
});
revalidatePath("/dashboard");
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
return { success: true };
}
export async function getEventParticipants(
@@ -4,6 +4,7 @@ import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { visibleAnnouncementAudiences } from "@/lib/announcement-access";
export const runtime = "nodejs";
@@ -37,6 +38,7 @@ export async function GET(
id,
announcement: {
kitaId: user.kitaId,
audience: { in: visibleAnnouncementAudiences(user) },
},
},
select: {
@@ -56,7 +58,7 @@ export async function GET(
headers: {
"Content-Type": attachment.fileType,
"Content-Disposition": contentDisposition(attachment.fileName),
"Cache-Control": "private, max-age=300",
"Cache-Control": "private, no-store",
},
});
} catch (error) {
@@ -3,6 +3,7 @@
import { useMemo, useState, useTransition } from "react";
import {
EventParticipationStatus,
RsvpMode,
TerminStatus,
TerminType,
} from "@prisma/client";
@@ -19,6 +20,7 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { suggestedRsvpMode } from "@/lib/rsvp";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -48,10 +50,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import {
createTerminAdmin,
updateTerminAdmin,
} from "../../kalender/actions";
import { createTerminAdmin, updateTerminAdmin } from "../../kalender/actions";
type AdminTerminDto = {
id: string;
@@ -64,6 +63,12 @@ type AdminTerminDto = {
allDay: boolean;
mitbringselListEnabled: boolean;
requiresRsvp: boolean;
rsvpMode: RsvpMode;
familyParticipations: {
familyId: string;
status: EventParticipationStatus;
adultCount: number;
}[];
rsvpDeadline: string | null;
participantInfo: string | null;
participations: {
@@ -81,9 +86,11 @@ type ChildDto = {
export function AdminCalendarManager({
termine,
childRows,
familyRows,
}: {
termine: AdminTerminDto[];
childRows: ChildDto[];
familyRows: { id: string; name: string }[];
}) {
const rsvpCount = termine.filter((termin) => termin.requiresRsvp).length;
const pendingCount = termine.filter(
@@ -104,8 +111,8 @@ export function AdminCalendarManager({
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Termine verwalten, RSVP aktivieren und den Rücklauf pro Kind
verfolgen.
Termine verwalten, RSVP aktivieren und den Rücklauf pro Familie oder
Kind verfolgen.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
@@ -134,7 +141,7 @@ export function AdminCalendarManager({
Noch keine Termine
</h2>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
</p>
</div>
</Card>
@@ -145,6 +152,7 @@ export function AdminCalendarManager({
key={termin.id}
termin={termin}
childRows={childRows}
familyRows={familyRows}
/>
))}
</div>
@@ -156,11 +164,25 @@ export function AdminCalendarManager({
function AdminTerminCard({
termin,
childRows,
familyRows,
}: {
termin: AdminTerminDto;
childRows: ChildDto[];
familyRows: { id: string; name: string }[];
}) {
const stats = useMemo(() => getStats(termin, childRows), [termin, childRows]);
const isFamily = termin.rsvpMode === RsvpMode.FAMILY;
const rows = useMemo(
() =>
isFamily
? familyRows.map((family) => ({
id: family.id,
name: family.name,
familyName: family.name,
}))
: childRows,
[isFamily, familyRows, childRows],
);
const stats = useMemo(() => getStats(termin, rows), [termin, rows]);
return (
<Card className="overflow-hidden">
@@ -175,7 +197,9 @@ function AdminTerminCard({
{getStatusLabel(termin.status)}
</Badge>
{termin.requiresRsvp && (
<Badge variant="warning">RSVP aktiv</Badge>
<Badge variant="warning">
Antwort pro {isFamily ? "Familie" : "Kind"}
</Badge>
)}
</div>
<CardTitle className="text-lg">{termin.title}</CardTitle>
@@ -217,14 +241,19 @@ function AdminTerminCard({
<div>
<p className="font-medium">
{stats.attending} Zugesagt / {stats.notAttending} Abgesagt /{" "}
{stats.pending} Ausstehend
{stats.pending} Ausstehend{" "}
{isFamily ? `· ${stats.adultCount} Erwachsene` : ""}
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Frist:{" "}
{termin.rsvpDeadline
? format(new Date(termin.rsvpDeadline), "dd.MM.yyyy HH:mm", {
locale: de,
})
? format(
new Date(termin.rsvpDeadline),
"dd.MM.yyyy HH:mm",
{
locale: de,
},
)
: "bis Terminbeginn"}
</p>
</div>
@@ -241,18 +270,24 @@ function AdminTerminCard({
<Table>
<TableHeader>
<TableRow>
<TableHead>Kind</TableHead>
<TableHead>Familie</TableHead>
<TableHead>{isFamily ? "Familie" : "Kind"}</TableHead>
<TableHead>{isFamily ? "Erwachsene" : "Familie"}</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{childRows.map((child) => {
{rows.map((child) => {
const status = stats.statusByChildId.get(child.id) ?? null;
return (
<TableRow key={child.id}>
<TableCell className="font-medium">{child.name}</TableCell>
<TableCell>{child.familyName}</TableCell>
<TableCell className="font-medium">
{child.name}
</TableCell>
<TableCell>
{isFamily
? (stats.adultCounts.get(child.id) ?? "–")
: child.familyName}
</TableCell>
<TableCell>
<RsvpStatusBadge status={status} />
</TableCell>
@@ -283,6 +318,13 @@ function TerminFormDialog({
const [requiresRsvp, setRequiresRsvp] = useState(
termin?.requiresRsvp ?? false,
);
const [rsvpMode, setRsvpMode] = useState(
termin?.rsvpMode ?? suggestedRsvpMode(TerminType.KITA_FEST),
);
const [modeTouched, setModeTouched] = useState(false);
const modeLocked =
!!termin &&
termin.participations.length + termin.familyParticipations.length > 0;
const [isPending, startTransition] = useTransition();
function handleAction(formData: FormData) {
@@ -300,6 +342,7 @@ function TerminFormDialog({
allDay: formData.get("allDay") === "on",
mitbringselListEnabled: formData.get("mitbringselListEnabled") === "on",
requiresRsvp: hasRsvp,
rsvpMode,
rsvpDeadline:
hasRsvp && deadlineRaw ? new Date(deadlineRaw).toISOString() : null,
participantInfo: String(formData.get("participantInfo") ?? ""),
@@ -330,6 +373,10 @@ function TerminFormDialog({
setOpen(nextOpen);
if (nextOpen) {
setRequiresRsvp(termin?.requiresRsvp ?? false);
setRsvpMode(
termin?.rsvpMode ?? suggestedRsvpMode(TerminType.KITA_FEST),
);
setModeTouched(false);
}
}}
>
@@ -380,6 +427,12 @@ function TerminFormDialog({
<select
id={`${mode}-type`}
name="type"
onChange={(event) => {
if (!termin && !modeLocked && !modeTouched)
setRsvpMode(
suggestedRsvpMode(event.target.value as TerminType),
);
}}
defaultValue={termin?.type ?? TerminType.KITA_FEST}
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:bg-[oklch(0.99_0.005_75)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
required
@@ -453,9 +506,33 @@ function TerminFormDialog({
{requiresRsvp && (
<div className="grid gap-4 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4">
<div className="grid gap-2">
<Label htmlFor={`${mode}-rsvpDeadline`}>
Rückmeldefrist
</Label>
<Label htmlFor={`${mode}-rsvpMode`}>Antwortart</Label>
<select
id={`${mode}-rsvpMode`}
value={rsvpMode}
disabled={modeLocked}
onChange={(e) => {
setRsvpMode(e.target.value as RsvpMode);
setModeTouched(true);
}}
className="h-10 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 text-sm disabled:opacity-60"
>
<option value="FAMILY">
Pro Familie · mit Anzahl Erwachsener
</option>
<option value="CHILD">
Pro Kind · einzelne Zu-/Absage
</option>
</select>
{modeLocked ? (
<p className="text-xs text-[var(--ink-soft)]">
Die Antwortart ist gesperrt, da bereits Rückmeldungen
vorliegen.
</p>
) : null}
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-rsvpDeadline`}>Rückmeldefrist</Label>
<Input
id={`${mode}-rsvpDeadline`}
name="rsvpDeadline"
@@ -532,28 +609,34 @@ function RsvpStatusBadge({
);
}
function getStats(termin: AdminTerminDto, children: ChildDto[]) {
const statusByChildId = new Map(
termin.participations.map((participation) => [
participation.childId,
participation.status,
]),
function getStats(termin: AdminTerminDto, rows: ChildDto[]) {
const isFamily = termin.rsvpMode === RsvpMode.FAMILY;
const statusByChildId = new Map<string, EventParticipationStatus>(
isFamily
? termin.familyParticipations.map((row) => [row.familyId, row.status])
: termin.participations.map((row) => [row.childId, row.status]),
);
const adultCounts = new Map(
termin.familyParticipations.map((row) => [row.familyId, row.adultCount]),
);
let attending = 0;
let notAttending = 0;
children.forEach((child) => {
const status = statusByChildId.get(child.id);
if (status === EventParticipationStatus.ATTENDING) attending += 1;
let adultCount = 0;
for (const row of rows) {
const status = statusByChildId.get(row.id);
if (status === EventParticipationStatus.ATTENDING) {
attending += 1;
adultCount += adultCounts.get(row.id) ?? 0;
}
if (status === EventParticipationStatus.NOT_ATTENDING) notAttending += 1;
});
}
return {
attending,
notAttending,
pending: Math.max(children.length - attending - notAttending, 0),
pending: rows.length - attending - notAttending,
statusByChildId,
adultCounts,
adultCount,
};
}
+11 -1
View File
@@ -14,7 +14,7 @@ export default async function AdminKalenderPage() {
return <div className="p-8">Kein Mandant zugeordnet.</div>;
}
const [termine, children] = await Promise.all([
const [termine, children, families] = await Promise.all([
prisma.termin.findMany({
where: { kitaId },
select: {
@@ -28,6 +28,10 @@ export default async function AdminKalenderPage() {
allDay: true,
mitbringselListEnabled: true,
requiresRsvp: true,
rsvpMode: true,
familyParticipations: {
select: { familyId: true, status: true, adultCount: true },
},
rsvpDeadline: true,
participantInfo: true,
participations: {
@@ -60,6 +64,11 @@ export default async function AdminKalenderPage() {
{ firstName: "asc" },
],
}),
prisma.family.findMany({
where: { kitaId },
select: { id: true, name: true },
orderBy: { name: "asc" },
}),
]);
return (
@@ -70,6 +79,7 @@ export default async function AdminKalenderPage() {
endDate: termin.endDate.toISOString(),
rsvpDeadline: termin.rsvpDeadline?.toISOString() ?? null,
}))}
familyRows={families}
childRows={children.map((child) => ({
id: child.id,
name: `${child.firstName} ${child.lastName}`,
+20 -16
View File
@@ -64,7 +64,9 @@ export function NewsForm() {
rows={10}
value={content}
onChange={(event) => setContent(event.target.value)}
placeholder={"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."}
placeholder={
"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."
}
required
/>
<p className="text-xs text-[var(--ink-muted)]">
@@ -110,21 +112,23 @@ export function NewsForm() {
</Label>
</div>
{sendEmail && (
<div className="mt-4 grid gap-2">
<Label>Empfänger</Label>
<Select name="audience" defaultValue="ELTERN">
<SelectTrigger>
<SelectValue placeholder="Empfänger auswählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ELTERN">Eltern</SelectItem>
<SelectItem value="ERZIEHER">ErzieherInnen</SelectItem>
<SelectItem value="ALLE">Alle</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="mt-4 grid gap-2">
<Label htmlFor="news-audience">Empfängergruppe</Label>
<p className="text-xs text-[var(--ink-soft)]">
Bestimmt, wer die Meldung und Anhänge in der App sehen darf und
optional die E-Mail erhält.
</p>
<Select name="audience" defaultValue="ELTERN">
<SelectTrigger id="news-audience">
<SelectValue placeholder="Empfänger auswählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ELTERN">Eltern</SelectItem>
<SelectItem value="ERZIEHER">ErzieherInnen</SelectItem>
<SelectItem value="ALLE">Eltern und ErzieherInnen</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end">
+7 -2
View File
@@ -13,6 +13,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { announcementAudienceLabels } from "@/lib/announcement-access";
import { NewsForm } from "./news-form";
export const metadata = { title: "Schwarzes Brett · Kita-Planer" };
@@ -30,6 +31,7 @@ export default async function AdminNewsPage() {
select: {
id: true,
title: true,
audience: true,
createdAt: true,
attachments: {
select: {
@@ -63,8 +65,8 @@ export default async function AdminNewsPage() {
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Veröffentliche verbindliche Ankündigungen für deine Kita. Anhänge
werden geschützt gespeichert und nur für eingeloggte Nutzer
derselben Kita ausgeliefert.
werden geschützt gespeichert und nur an die gewählte Empfängergruppe
deiner Kita ausgeliefert.
</p>
</div>
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] tabular-nums">
@@ -127,6 +129,9 @@ export default async function AdminNewsPage() {
</Badge>
)}
</div>
<Badge variant="outline" className="mb-2">
{announcementAudienceLabels[announcement.audience]}
</Badge>
<p className="text-xs text-[var(--ink-muted)]">
{format(announcement.createdAt, "dd.MM.yyyy HH:mm", {
locale: de,
@@ -2,10 +2,11 @@
import type { ComponentProps, FormEvent } from "react";
import { useEffect, useMemo, useState, useTransition } from "react";
import { TerminType } from "@prisma/client";
import { RsvpMode, TerminType } from "@prisma/client";
import { Plus } from "lucide-react";
import { toast } from "sonner";
import { suggestedRsvpMode } from "@/lib/rsvp";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -28,6 +29,11 @@ type AdminTerminFormState = {
startDate: string;
endDate: string;
allDay: boolean;
requiresRsvp: boolean;
rsvpMode: RsvpMode;
rsvpDeadline: string;
participantInfo: string;
mitbringselListEnabled: boolean;
};
const initialFormState: AdminTerminFormState = {
@@ -37,6 +43,11 @@ const initialFormState: AdminTerminFormState = {
startDate: "",
endDate: "",
allDay: false,
requiresRsvp: false,
rsvpMode: RsvpMode.FAMILY,
rsvpDeadline: "",
participantInfo: "",
mitbringselListEnabled: false,
};
type AdminTerminModalProps = {
@@ -51,6 +62,12 @@ type AdminTerminModalProps = {
startDate: Date;
endDate: Date;
allDay: boolean;
requiresRsvp: boolean;
rsvpMode: RsvpMode;
rsvpDeadline: Date | null;
participantInfo: string | null;
mitbringselListEnabled: boolean;
hasRsvpResponses: boolean;
};
};
@@ -61,10 +78,12 @@ export function AdminTerminModal({
existingTermin,
}: AdminTerminModalProps) {
const [open, setOpen] = useState(false);
const [formState, setFormState] = useState<AdminTerminFormState>(initialFormState);
const [formState, setFormState] =
useState<AdminTerminFormState>(initialFormState);
const [modeTouched, setModeTouched] = useState(false);
const modeLocked = existingTermin?.hasRsvpResponses ?? false;
const [isPending, startTransition] = useTransition();
const minDateTime = useMemo(() => getLocalDateTimeInputValue(new Date()), []);
const minEndDateTime = formState.startDate || minDateTime;
// Initialize and reset form fields when modal opens
useEffect(() => {
@@ -78,10 +97,18 @@ export function AdminTerminModal({
startDate: getLocalDateTimeInputValue(existingTermin.startDate),
endDate: getLocalDateTimeInputValue(existingTermin.endDate),
allDay: existingTermin.allDay,
requiresRsvp: existingTermin.requiresRsvp,
rsvpMode: existingTermin.rsvpMode,
rsvpDeadline: existingTermin.rsvpDeadline
? getLocalDateTimeInputValue(existingTermin.rsvpDeadline)
: "",
participantInfo: existingTermin.participantInfo ?? "",
mitbringselListEnabled: existingTermin.mitbringselListEnabled,
});
} else {
setFormState(initialFormState);
}
setModeTouched(false);
}
}, [open, existingTermin]);
@@ -91,7 +118,14 @@ export function AdminTerminModal({
) => {
setFormState((current) => {
const next = { ...current, [key]: value };
if (key === "startDate" && typeof value === "string" && next.endDate && next.endDate < value) {
if (key === "type" && !existingTermin && !modeTouched && !modeLocked)
next.rsvpMode = suggestedRsvpMode(value as TerminType);
if (
key === "startDate" &&
typeof value === "string" &&
next.endDate &&
next.endDate < value
) {
next.endDate = value;
}
return next;
@@ -122,6 +156,13 @@ export function AdminTerminModal({
startDate: new Date(formState.startDate).toISOString(),
endDate: new Date(formState.endDate).toISOString(),
allDay: formState.allDay,
requiresRsvp: formState.requiresRsvp,
rsvpMode: formState.rsvpMode,
rsvpDeadline: formState.rsvpDeadline
? new Date(formState.rsvpDeadline).toISOString()
: null,
participantInfo: formState.participantInfo,
mitbringselListEnabled: formState.mitbringselListEnabled,
};
startTransition(async () => {
@@ -138,7 +179,7 @@ export function AdminTerminModal({
toast.success(
existingTermin
? "Termin wurde erfolgreich aktualisiert!"
: "Termin wurde direkt angelegt!"
: "Termin wurde direkt angelegt!",
);
if (!existingTermin) {
setFormState(initialFormState);
@@ -156,11 +197,13 @@ export function AdminTerminModal({
{triggerLabel}
</Button>
</DialogTrigger>
<DialogContent>
<DialogContent className="max-h-[85vh] overflow-y-auto">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>
{existingTermin ? "Termin bearbeiten (Admin)" : "Termin anlegen (Admin)"}
{existingTermin
? "Termin bearbeiten (Admin)"
: "Termin anlegen (Admin)"}
</DialogTitle>
<DialogDescription>
{existingTermin
@@ -189,7 +232,9 @@ export function AdminTerminModal({
name="description"
placeholder="Details zum Termin..."
value={formState.description}
onChange={(event) => updateField("description", event.target.value)}
onChange={(event) =>
updateField("description", event.target.value)
}
/>
</div>
@@ -200,18 +245,28 @@ export function AdminTerminModal({
name="type"
value={formState.type}
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
onChange={(event) => updateField("type", event.target.value as TerminType)}
onChange={(event) =>
updateField("type", event.target.value as TerminType)
}
required
>
<option value={TerminType.KITA_FEST}>Kita-Fest</option>
<option value={TerminType.SCHLIESSTAG}>Schließtag</option>
<option value={TerminType.TEAMTAG}>Teamtag (Kita geschlossen)</option>
<option value={TerminType.TEAMTAG}>
Teamtag (Kita geschlossen)
</option>
<option value={TerminType.MITMACH_TAG}>Mitmach-Tag</option>
<option value={TerminType.ELTERNABEND}>Elternabend</option>
<option value={TerminType.MITGLIEDERVERSAMMLUNG}>Mitgliederversammlung</option>
<option value={TerminType.MITGLIEDERVERSAMMLUNG}>
Mitgliederversammlung
</option>
<option value={TerminType.ELTERNCAFE}>Elterncafe</option>
<option value={TerminType.GEBURTSTAG_INTERN}>Geburtstag (Intern)</option>
<option value={TerminType.GEBURTSTAG_EXTERN}>Raumanfrage (Extern)</option>
<option value={TerminType.GEBURTSTAG_INTERN}>
Geburtstag (Intern)
</option>
<option value={TerminType.GEBURTSTAG_EXTERN}>
Raumanfrage (Extern)
</option>
<option value={TerminType.SONSTIGES}>Sonstiges</option>
</select>
</div>
@@ -225,7 +280,9 @@ export function AdminTerminModal({
type="datetime-local"
min={existingTermin ? undefined : minDateTime}
value={formState.startDate}
onChange={(event) => updateField("startDate", event.target.value)}
onChange={(event) =>
updateField("startDate", event.target.value)
}
required
/>
</div>
@@ -235,14 +292,82 @@ export function AdminTerminModal({
id="endDate"
name="endDate"
type="datetime-local"
min={formState.startDate || (existingTermin ? undefined : minDateTime)}
min={
formState.startDate ||
(existingTermin ? undefined : minDateTime)
}
value={formState.endDate}
onChange={(event) => updateField("endDate", event.target.value)}
onChange={(event) =>
updateField("endDate", event.target.value)
}
required
/>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={formState.mitbringselListEnabled}
onChange={(e) =>
updateField("mitbringselListEnabled", e.target.checked)
}
/>
Mitbring-Liste aktiv
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={formState.requiresRsvp}
onChange={(e) => updateField("requiresRsvp", e.target.checked)}
/>
Teilnahme-Abfrage aktivieren
</label>
{formState.requiresRsvp ? (
<div className="grid gap-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4">
<Label htmlFor="calendar-rsvp-mode">Antwortart</Label>
<select
id="calendar-rsvp-mode"
value={formState.rsvpMode}
disabled={modeLocked}
onChange={(e) => {
updateField("rsvpMode", e.target.value as RsvpMode);
setModeTouched(true);
}}
className="h-10 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 text-sm"
>
<option value="FAMILY">
Pro Familie · mit Anzahl Erwachsener
</option>
<option value="CHILD">Pro Kind · einzelne Zu-/Absage</option>
</select>
{modeLocked ? (
<p className="text-xs">
Die Antwortart ist wegen vorhandener Rückmeldungen gesperrt.
</p>
) : null}
<Label htmlFor="calendar-rsvp-deadline">
Rückmeldefrist (sonst Terminbeginn)
</Label>
<Input
id="calendar-rsvp-deadline"
type="datetime-local"
value={formState.rsvpDeadline}
onChange={(e) => updateField("rsvpDeadline", e.target.value)}
/>
<Label htmlFor="calendar-participant-info">
Hinweise für Teilnehmer
</Label>
<Textarea
id="calendar-participant-info"
value={formState.participantInfo}
onChange={(e) =>
updateField("participantInfo", e.target.value)
}
/>
</div>
) : null}
<div className="flex items-center gap-2 mt-2">
<input
type="checkbox"
@@ -250,7 +375,9 @@ export function AdminTerminModal({
name="allDay"
checked={formState.allDay}
className="rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
onChange={(event) => updateField("allDay", event.target.checked)}
onChange={(event) =>
updateField("allDay", event.target.checked)
}
/>
<Label htmlFor="allDay" className="font-normal cursor-pointer">
Ganztägiger Termin
@@ -259,11 +386,19 @@ export function AdminTerminModal({
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Speichern..." : existingTermin ? "Änderungen speichern" : "Termin erstellen"}
{isPending
? "Speichern..."
: existingTermin
? "Änderungen speichern"
: "Termin erstellen"}
</Button>
</DialogFooter>
</form>
@@ -30,6 +30,7 @@ import {
WashingMachine,
Trash2,
} from "lucide-react";
import { RsvpDialog, RsvpForm } from "@/components/rsvp-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -522,6 +523,11 @@ export function CalendarOverviewClient({
</p>
)}
{item.rsvpEvent ? <section className="mt-4 border-t border-[var(--hairline)] pt-4">
<h5 className="mb-3 font-medium">Eure Rückmeldung</h5>
<RsvpForm key={JSON.stringify(item.rsvpEvent)} event={item.rsvpEvent} />
</section> : null}
{item.participantInfo && item.showParticipantInfo && (
<div className="mt-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-3 text-xs text-[var(--ink)]">
<div className="mb-0.5 flex items-center gap-1.5 font-medium">
@@ -999,6 +1005,8 @@ function AgendaView({
</div>
</div>
{item.kind === "termin" && item.rsvpEvent ? <RsvpDialog event={item.rsvpEvent} /> : null}
{/* Actions for admins */}
{isAdmin && item.kind === "termin" && (
<div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
@@ -2,7 +2,12 @@
import type { ReactNode } from "react";
import { useState, useTransition } from "react";
import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client";
import {
DutyAssignmentStatus,
RsvpMode,
TerminStatus,
TerminType,
} from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import {
@@ -15,6 +20,8 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { RsvpForm } from "@/components/rsvp-form";
import type { RsvpEventDto } from "@/lib/rsvp";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -44,6 +51,10 @@ export type TerminListItemDto = {
allDay: boolean;
mitbringselListEnabled: boolean;
requiresRsvp: boolean;
rsvpMode: RsvpMode;
rsvpDeadline: Date | null;
hasRsvpResponses: boolean;
rsvpEvent?: RsvpEventDto;
participantInfo: string | null;
showParticipantInfo: boolean;
mitbringselItems?: {
@@ -88,7 +99,10 @@ export type NotdienstSwapOptionDto = {
familyName: string;
};
export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto | NotdienstCalendarItemDto;
export type CalendarListItemDto =
| TerminListItemDto
| DutyCalendarItemDto
| NotdienstCalendarItemDto;
export function TerminList({
termine,
@@ -116,7 +130,10 @@ export function TerminList({
</p>
<div className="mt-5">
{isAdmin ? (
<AdminTerminModal triggerLabel="+ Termin anlegen" triggerClassName="" />
<AdminTerminModal
triggerLabel="+ Termin anlegen"
triggerClassName=""
/>
) : (
<TerminRequestModal
triggerLabel="+ Termin anlegen"
@@ -141,14 +158,19 @@ export function TerminList({
{group.label}
</h2>
<span className="rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-[var(--ink-soft)] tabular-nums">
{group.items.length} {group.items.length === 1 ? "Termin" : "Termine"}
{group.items.length}{" "}
{group.items.length === 1 ? "Termin" : "Termine"}
</span>
</div>
<Card className="overflow-hidden p-0">
<div className="divide-y divide-[var(--hairline-soft)]">
{group.items.map((item) =>
item.kind === "duty" ? (
<DutyRow key={`duty-${item.id}`} duty={item} isAdmin={isAdmin} />
<DutyRow
key={`duty-${item.id}`}
duty={item}
isAdmin={isAdmin}
/>
) : item.kind === "notdienst" ? (
<NotdienstRow
key={`notdienst-${item.id}`}
@@ -157,7 +179,12 @@ export function TerminList({
notdienstSwapOptions={notdienstSwapOptions}
/>
) : (
<TerminRow key={`termin-${item.id}`} termin={item} userId={userId} isAdmin={isAdmin} />
<TerminRow
key={`termin-${item.id}`}
termin={item}
userId={userId}
isAdmin={isAdmin}
/>
),
)}
</div>
@@ -168,13 +195,22 @@ export function TerminList({
);
}
function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolean }) {
function DutyRow({
duty,
isAdmin,
}: {
duty: DutyCalendarItemDto;
isAdmin: boolean;
}) {
const color = duty.isOwn ? "oklch(0.60 0.16 160)" : "oklch(0.75 0.08 160)";
const [menuOpen, setMenuOpen] = useState(false);
return (
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
<div
className="absolute inset-y-3 left-0 w-[3px] rounded-r-full"
style={{ backgroundColor: color }}
/>
<DateTile date={duty.startDate} />
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
@@ -207,7 +243,10 @@ function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolea
</Button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div
className="fixed inset-0 z-40"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<a
href="/dashboard/admin/dienste"
@@ -257,7 +296,10 @@ function TerminRow({
<>
<Dialog>
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
<div
className="absolute inset-y-3 left-0 w-[3px] rounded-r-full"
style={{ backgroundColor: color }}
/>
<DateTile date={termin.startDate} />
<div className="min-w-0">
<div className="flex items-center gap-2">
@@ -276,7 +318,9 @@ function TerminRow({
</p>
</div>
<div className="flex items-center gap-2 relative">
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
<CategoryPill color={color}>
{getTypeLabel(termin.type)}
</CategoryPill>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
Details
@@ -297,7 +341,10 @@ function TerminRow({
</Button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div
className="fixed inset-0 z-40"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 mt-1 w-48 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<AdminTerminModal
existingTermin={termin}
@@ -342,7 +389,9 @@ function TerminRow({
<div className="grid gap-4">
<div className="flex">
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
<CategoryPill color={color}>
{getTypeLabel(termin.type)}
</CategoryPill>
</div>
{termin.description ? (
@@ -355,6 +404,16 @@ function TerminRow({
</p>
)}
{termin.rsvpEvent ? (
<section className="rounded-lg border border-[var(--hairline)] p-4">
<h3 className="mb-3 font-medium">Eure Rückmeldung</h3>
<RsvpForm
key={JSON.stringify(termin.rsvpEvent)}
event={termin.rsvpEvent}
/>
</section>
) : null}
{termin.participantInfo && termin.showParticipantInfo && (
<div className="rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-sm text-[var(--ink)]">
<div className="mb-1 flex items-center gap-2 font-medium">
@@ -385,8 +444,9 @@ function TerminRow({
<DialogHeader>
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
<DialogDescription>
Möchtest du den Termin &ldquo;{termin.title}&rdquo; wirklich löschen? Alle
zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren.
Möchtest du den Termin &ldquo;{termin.title}&rdquo; wirklich
löschen? Alle zugehörigen Teilnahmen und Mitbringsel-Einträge
gehen verloren.
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -436,14 +496,20 @@ function CategoryPill({
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-[var(--ink-soft)]"
style={{ color }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: color }} />
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: color }}
/>
{children}
</span>
);
}
function groupByMonth(items: CalendarListItemDto[]) {
const groups = new Map<string, { key: string; label: string; items: CalendarListItemDto[] }>();
const groups = new Map<
string,
{ key: string; label: string; items: CalendarListItemDto[] }
>();
items.forEach((item) => {
const key = format(item.startDate, "yyyy-MM");
@@ -559,7 +625,8 @@ export function NotdienstSwapDialog({
<DialogTitle>Notdienst tauschen</DialogTitle>
<DialogDescription>
Frage Familie {target.familyName}, ob ihr den Notdienst am{" "}
{format(target.startDate, "dd.MM.yyyy", { locale: de })} tauschen könnt.
{format(target.startDate, "dd.MM.yyyy", { locale: de })} tauschen
könnt.
</DialogDescription>
</DialogHeader>
@@ -575,11 +642,14 @@ export function NotdienstSwapDialog({
onChange={(event) => setOfferedAssignmentId(event.target.value)}
>
{selectableOptions.length === 0 ? (
<option value="">Kein eigener zukünftiger Notdienst verfügbar</option>
<option value="">
Kein eigener zukünftiger Notdienst verfügbar
</option>
) : (
selectableOptions.map((option) => (
<option key={option.id} value={option.id}>
{format(option.date, "dd.MM.yyyy", { locale: de })} - {option.childName}
{format(option.date, "dd.MM.yyyy", { locale: de })} -{" "}
{option.childName}
</option>
))
)}
@@ -603,7 +673,11 @@ export function NotdienstSwapDialog({
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={isPending}>
<Button
variant="ghost"
onClick={() => setOpen(false)}
disabled={isPending}
>
Abbrechen
</Button>
<Button
@@ -632,7 +706,10 @@ function NotdienstRow({
return (
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
<div
className="absolute inset-y-3 left-0 w-[3px] rounded-r-full"
style={{ backgroundColor: color }}
/>
<DateTile date={notdienst.startDate} />
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
@@ -640,7 +717,8 @@ function NotdienstRow({
</h3>
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(notdienst.startDate, "dd.MM.yyyy", { locale: de })} (ganztägig)
{format(notdienst.startDate, "dd.MM.yyyy", { locale: de })}{" "}
(ganztägig)
<span className="inline-flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--ink-muted)]" />
Kind: {notdienst.childName}
@@ -666,7 +744,10 @@ function NotdienstRow({
</Button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div
className="fixed inset-0 z-40"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<a
href={`/dashboard/notdienst/plan?year=${notdienst.planYear}&month=${notdienst.planMonth}`}
@@ -684,7 +765,11 @@ function NotdienstRow({
target={notdienst}
options={notdienstSwapOptions}
trigger={
<Button size="sm" variant="outline" className="h-8 px-2.5 text-xs">
<Button
size="sm"
variant="outline"
className="h-8 px-2.5 text-xs"
>
Tausch anfragen
</Button>
}
+73 -18
View File
@@ -6,6 +6,7 @@ import {
NotdienstAlertStatus,
NotdienstPlanStatus,
NotdienstSwapStatus,
RsvpMode,
TerminStatus,
TerminType,
UserRole,
@@ -18,6 +19,7 @@ import { NotdienstSwapEmail } from "@/emails/NotdienstSwapEmail";
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma";
import { lockEvent } from "@/lib/event-lock";
// =====================================================================
// /dashboard/kalender · Server Actions
@@ -30,8 +32,9 @@ const terminSchema = z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
allDay: z.boolean().default(false),
mitbringselListEnabled: z.boolean().default(false),
requiresRsvp: z.boolean().default(false),
mitbringselListEnabled: z.boolean().optional(),
requiresRsvp: z.boolean().optional(),
rsvpMode: z.nativeEnum(RsvpMode).optional(),
rsvpDeadline: z.string().datetime().nullable().optional(),
participantInfo: z.string().trim().max(1000).optional(),
});
@@ -274,13 +277,16 @@ async function notifyFamilyAboutSwap({
);
}
function buildTerminData(data: z.infer<typeof terminSchema>) {
function buildTerminData(
data: z.infer<typeof terminSchema>,
allowPast = false,
) {
const startDate = new Date(data.startDate);
const endDate = new Date(data.endDate);
const now = new Date();
now.setSeconds(0, 0);
if (startDate < now) {
if (!allowPast && startDate < now) {
return {
error: "Der Startzeitpunkt darf nicht in der Vergangenheit liegen.",
};
@@ -290,6 +296,16 @@ function buildTerminData(data: z.infer<typeof terminSchema>) {
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
}
if (
data.requiresRsvp &&
data.rsvpDeadline &&
new Date(data.rsvpDeadline) > startDate
) {
return {
error: "Die Rückmeldefrist darf nicht nach dem Terminbeginn liegen.",
};
}
return {
data: {
title: data.title,
@@ -298,8 +314,9 @@ function buildTerminData(data: z.infer<typeof terminSchema>) {
startDate,
endDate,
allDay: data.allDay,
mitbringselListEnabled: data.mitbringselListEnabled,
requiresRsvp: data.requiresRsvp,
mitbringselListEnabled: data.mitbringselListEnabled ?? false,
requiresRsvp: data.requiresRsvp ?? false,
rsvpMode: data.rsvpMode ?? RsvpMode.CHILD,
rsvpDeadline:
data.requiresRsvp && data.rsvpDeadline
? new Date(data.rsvpDeadline)
@@ -852,6 +869,7 @@ export async function createTerminAdmin(rawPayload: unknown) {
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Erstellen des Termins:", error);
@@ -859,7 +877,10 @@ export async function createTerminAdmin(rawPayload: unknown) {
}
}
export async function updateTerminAdmin(terminId: string, rawPayload: unknown) {
export async function updateTerminAdmin(
terminId: string,
rawPayload: unknown,
): Promise<{ error?: string; success?: boolean }> {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
@@ -872,19 +893,50 @@ export async function updateTerminAdmin(terminId: string, rawPayload: unknown) {
return { error: "Ungültige Eingabedaten." };
}
const terminData = buildTerminData(parsed.data);
if ("error" in terminData) {
return { error: terminData.error };
}
try {
await prisma.termin.update({
where: {
id: terminId,
kitaId,
},
data: terminData.data,
const result = await prisma.$transaction(async (tx) => {
await lockEvent(tx, terminId, kitaId);
const existing = await tx.termin.findFirst({
where: { id: terminId, kitaId },
include: {
_count: {
select: { participations: true, familyParticipations: true },
},
},
});
if (!existing) return { error: "Termin wurde nicht gefunden." };
const data = {
...parsed.data,
mitbringselListEnabled:
parsed.data.mitbringselListEnabled ?? existing.mitbringselListEnabled,
requiresRsvp: parsed.data.requiresRsvp ?? existing.requiresRsvp,
rsvpMode: parsed.data.rsvpMode ?? existing.rsvpMode,
rsvpDeadline:
parsed.data.rsvpDeadline === undefined
? (existing.rsvpDeadline?.toISOString() ?? null)
: parsed.data.rsvpDeadline,
participantInfo:
parsed.data.participantInfo ?? existing.participantInfo ?? "",
};
if (
data.rsvpMode !== existing.rsvpMode &&
existing._count.participations + existing._count.familyParticipations >
0
) {
return {
error:
"Die Antwortart kann nach der ersten Rückmeldung nicht mehr geändert werden.",
};
}
const terminData = buildTerminData(data, true);
if ("error" in terminData) return { error: terminData.error };
await tx.termin.update({
where: { id: terminId, kitaId },
data: terminData.data,
});
return { success: true };
});
if (result.error) return result;
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
@@ -918,6 +970,7 @@ export async function approveTermin(terminId: string) {
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Bestätigen des Termins:", error);
@@ -948,6 +1001,7 @@ export async function rejectTermin(terminId: string, reason?: string) {
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Ablehnen des Termins:", error);
@@ -978,6 +1032,7 @@ export async function toggleMitbringselList(
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Umschalten der Mitbring-Liste:", error);
+95 -66
View File
@@ -9,9 +9,16 @@ import { HelpCircle } from "lucide-react";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getRsvpEvents } from "@/lib/rsvp-events";
import { AdminTerminModal } from "./_components/admin-termin-modal";
import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-anfragen";
import { type CalendarListItemDto, type NotdienstSwapOptionDto } from "./_components/termin-list";
import {
PendingAnfragen,
type PendingTerminDto,
} from "./_components/pending-anfragen";
import {
type CalendarListItemDto,
type NotdienstSwapOptionDto,
} from "./_components/termin-list";
import { TerminRequestModal } from "./_components/termin-request-modal";
import {
CalendarOverviewClient,
@@ -36,50 +43,62 @@ export default async function KalenderPage({
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const confirmedTermineRows = await prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.CONFIRMED,
},
select: {
id: true,
title: true,
description: true,
type: true,
status: true,
startDate: true,
endDate: true,
allDay: true,
mitbringselListEnabled: true,
requiresRsvp: true,
participantInfo: true,
mitbringselItems: {
select: {
id: true,
userId: true,
content: true,
user: {
select: {
firstName: true,
lastName: true,
const [rsvpEventRows, confirmedTermineRows] = await Promise.all([
getRsvpEvents(session),
prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.CONFIRMED,
},
select: {
id: true,
title: true,
description: true,
type: true,
status: true,
startDate: true,
endDate: true,
allDay: true,
mitbringselListEnabled: true,
requiresRsvp: true,
rsvpMode: true,
rsvpDeadline: true,
_count: {
select: { participations: true, familyParticipations: true },
},
familyParticipations: {
where: { familyId: familyIdForParticipation },
select: { status: true },
},
participantInfo: true,
mitbringselItems: {
select: {
id: true,
userId: true,
content: true,
user: {
select: {
firstName: true,
lastName: true,
},
},
},
},
},
participations: {
where: {
child: {
familyId: familyIdForParticipation,
participations: {
where: {
child: {
familyId: familyIdForParticipation,
},
},
select: {
childId: true,
status: true,
},
},
select: {
childId: true,
status: true,
},
},
},
orderBy: { startDate: "asc" },
});
orderBy: { startDate: "asc" },
}),
]);
const myPendingTermineRows = await prisma.termin.findMany({
where: {
@@ -98,6 +117,13 @@ export default async function KalenderPage({
allDay: true,
mitbringselListEnabled: true,
requiresRsvp: true,
rsvpMode: true,
rsvpDeadline: true,
_count: { select: { participations: true, familyParticipations: true } },
familyParticipations: {
where: { familyId: familyIdForParticipation },
select: { status: true },
},
participantInfo: true,
mitbringselItems: {
select: {
@@ -236,27 +262,32 @@ export default async function KalenderPage({
})
: [];
const rsvpEvents = new Map(rsvpEventRows.map((event) => [event.id, event]));
const mapTermin = (
termin: (typeof confirmedTermineRows)[number],
): CalendarListItemDto => {
const showParticipantInfo =
isAdmin ||
termin.familyParticipations.some(
(row) => row.status === EventParticipationStatus.ATTENDING,
) ||
termin.participations.some(
(row) => row.status === EventParticipationStatus.ATTENDING,
);
const { _count, familyParticipations, ...data } = termin;
void familyParticipations;
return {
...data,
kind: "termin",
showParticipantInfo,
participantInfo: showParticipantInfo ? termin.participantInfo : null,
hasRsvpResponses: _count.participations + _count.familyParticipations > 0,
rsvpEvent: rsvpEvents.get(termin.id),
};
};
const allUserTermine: CalendarListItemDto[] = [
...confirmedTermineRows.map((termin) => ({
...termin,
kind: "termin" as const,
showParticipantInfo:
isAdmin ||
termin.participations.some(
(participation) =>
participation.status === EventParticipationStatus.ATTENDING,
),
})),
...myPendingTermineRows.map((termin) => ({
...termin,
kind: "termin" as const,
showParticipantInfo:
isAdmin ||
termin.participations.some(
(participation) =>
participation.status === EventParticipationStatus.ATTENDING,
),
})),
...confirmedTermineRows.map(mapTermin),
...myPendingTermineRows.map(mapTermin),
...dutyAssignmentRows.map((assignment) => {
const isOwn = assignment.familyId === familyIdForParticipation;
return {
@@ -316,7 +347,9 @@ export default async function KalenderPage({
(request) => ({
id: request.id,
direction:
request.requesterFamilyId === session.user.familyId ? "outgoing" : "incoming",
request.requesterFamilyId === session.user.familyId
? "outgoing"
: "incoming",
targetDate: request.targetAssignment.date,
targetFamilyName: request.targetAssignment.child.family.name,
targetChildName: `${request.targetAssignment.child.firstName} ${request.targetAssignment.child.lastName}`,
@@ -367,9 +400,7 @@ export default async function KalenderPage({
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Was steht{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
an
</em>
<em className="font-normal italic text-[var(--accent-deep)]">an</em>
?
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
@@ -433,8 +464,6 @@ export default async function KalenderPage({
);
}
function TabBar({
currentTab,
overviewCount,
+67
View File
@@ -0,0 +1,67 @@
import Link from "next/link";
import { requireKitaSession } from "@/lib/auth-utils";
import { getAnnouncements } from "@/lib/announcements";
import { NewsTicker } from "../news-ticker";
import { Button } from "@/components/ui/button";
export const metadata = { title: "Mitteilungen · Kita-Planer" };
export default async function MitteilungenPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const session = await requireKitaSession();
const params = await searchParams;
const parsed = Number(params.page ?? 1);
const page =
Number.isSafeInteger(parsed) && parsed > 0 ? Math.min(parsed, 100000) : 1;
const pageSize = 12;
const items = await getAnnouncements(session, {
take: pageSize + 1,
skip: (page - 1) * pageSize,
});
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-deep)]">
SCHWARZES BRETT
</p>
<h1 className="mt-2 text-[36px] tracking-[-0.025em]">
Alle{" "}
<em className="font-normal text-[var(--accent-deep)]">
Mitteilungen
</em>
</h1>
<p className="mt-2 text-[15px] text-[var(--ink-soft)]">
Aktuelle und ältere Meldungen für deine Empfängergruppe.
</p>
</header>
<NewsTicker items={items.slice(0, pageSize)} archive />
<nav
aria-label="Mitteilungen durchblättern"
className="flex items-center justify-between gap-3"
>
<div>
{page > 1 ? (
<Button asChild variant="outline">
<Link href={`/dashboard/mitteilungen?page=${page - 1}`}>
Neuere Meldungen
</Link>
</Button>
) : null}
</div>
<span className="text-sm text-[var(--ink-soft)]">Seite {page}</span>
<div>
{items.length > pageSize ? (
<Button asChild variant="outline">
<Link href={`/dashboard/mitteilungen?page=${page + 1}`}>
Ältere Meldungen
</Link>
</Button>
) : null}
</div>
</nav>
</div>
);
}
+48 -17
View File
@@ -1,5 +1,7 @@
"use client";
import Link from "next/link";
import { toast } from "sonner";
import { useState, useTransition } from "react";
import { Bell, FileText, Megaphone } from "lucide-react";
@@ -26,6 +28,7 @@ export type NewsTickerItemDto = {
id: string;
title: string;
content: string;
excerpt: string;
createdAt: string;
authorName: string;
isUnread: boolean;
@@ -37,22 +40,32 @@ export type NewsTickerItemDto = {
}[];
};
export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
export function NewsTicker({
items,
archive = false,
}: {
items: NewsTickerItemDto[];
archive?: boolean;
}) {
const [selected, setSelected] = useState<NewsTickerItemDto | null>(null);
const [readIds, setReadIds] = useState<Set<string>>(new Set());
const [, startTransition] = useTransition();
if (items.length === 0) {
return null;
}
function openAnnouncement(item: NewsTickerItemDto) {
setSelected(item);
if (!item.isUnread || readIds.has(item.id)) return;
setReadIds((current) => new Set(current).add(item.id));
startTransition(async () => {
await markAnnouncementRead(item.id);
try {
const result = await markAnnouncementRead(item.id);
if (result.error) {
toast.error(result.error);
return;
}
setReadIds((current) => new Set(current).add(item.id));
} catch {
toast.error("Lesestatus konnte nicht gespeichert werden.");
}
});
}
@@ -60,20 +73,33 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
<>
<Card className="mb-8 border-[var(--good)] bg-[var(--good-soft)]">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2 text-lg">
<Megaphone className="h-5 w-5 text-[var(--good)] [stroke-width:1.7]" />
Schwarzes Brett
{archive ? "Mitteilungen" : "Neues aus der Kita"}
</CardTitle>
<CardDescription>
Die neuesten offiziellen Ankündigungen deiner Kita.
{archive
? "Veröffentlichte Ankündigungen deiner Kita."
: "Die neuesten offiziellen Ankündigungen deiner Kita."}
</CardDescription>
</div>
<Badge variant="secondary">{items.length}</Badge>
{archive ? (
<Badge variant="secondary">{items.length} Meldungen</Badge>
) : (
<Button asChild variant="outline" size="sm">
<Link href="/dashboard/mitteilungen">Alle Mitteilungen</Link>
</Button>
)}
</div>
</CardHeader>
<CardContent className="grid gap-3 lg:grid-cols-3">
{items.length === 0 ? (
<p className="col-span-full py-4 text-sm text-[var(--ink-soft)]">
Noch keine Mitteilungen für deine Empfängergruppe.
</p>
) : null}
{items.map((item) => {
const isUnread = item.isUnread && !readIds.has(item.id);
return (
@@ -88,15 +114,13 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
<Bell className="h-3.5 w-3.5 [stroke-width:1.7]" />
{item.createdAt}
</div>
{isUnread && (
<Badge variant="success">NEU</Badge>
)}
{isUnread && <Badge variant="success">NEU</Badge>}
</div>
<h3 className="line-clamp-2 font-semibold leading-snug text-[var(--ink)]">
{item.title}
</h3>
<p className="mt-2 line-clamp-3 text-sm leading-6 text-[var(--ink-soft)]">
{item.content}
{item.excerpt}
</p>
{item.attachments.length > 0 && (
<p className="mt-3 flex items-center gap-1 text-xs font-medium text-[var(--accent-deep)]">
@@ -111,7 +135,10 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
</CardContent>
</Card>
<Dialog open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
<Dialog
open={!!selected}
onOpenChange={(open) => !open && setSelected(null)}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
{selected && (
<>
@@ -137,7 +164,11 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
variant="outline"
className="justify-start"
>
<a href={attachment.fileUrl} target="_blank" rel="noreferrer">
<a
href={attachment.fileUrl}
target="_blank"
rel="noreferrer"
>
<FileText className="h-4 w-4 [stroke-width:1.7]" />
{attachment.fileName}
</a>
+13 -2
View File
@@ -18,6 +18,10 @@ import { getTargetMonthData } from "@/lib/date-utils";
import { prisma } from "@/lib/prisma";
import { Stat } from "@/components/ui/stat";
import { OnboardingDialog } from "@/components/OnboardingDialog";
import { NewsTicker } from "./news-ticker";
import { RsvpActionCard } from "./rsvp-action-card";
import { getAnnouncements } from "@/lib/announcements";
import { getRsvpEvents } from "@/lib/rsvp-events";
import { AbsenceCard, UpcomingAbsencesCard } from "./absence-card";
import { AlarmDashboardHints } from "./notdienst/alarm/dashboard-hints";
@@ -56,6 +60,8 @@ export default async function DashboardPage() {
onboardingUser,
absenceChildren,
upcomingAbsences,
announcements,
rsvpEvents,
] = await Promise.all([
prisma.family.count({
where: { kitaId: session.user.kitaId },
@@ -143,6 +149,8 @@ export default async function DashboardPage() {
take: 6,
})
: Promise.resolve([]),
getAnnouncements(session, { take: 3 }),
getRsvpEvents(session, { onlyOpen: true }),
]);
const canReportAbsences = session.user.role !== UserRole.ERZIEHER;
@@ -178,7 +186,7 @@ export default async function DashboardPage() {
}
/>
<header className="mb-9 flex items-start justify-between gap-8">
<header className="mb-9 flex items-start justify-between gap-8 max-[760px]:flex-col max-[760px]:gap-3">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
ÜBERSICHT · {kita.name}
@@ -195,13 +203,16 @@ export default async function DashboardPage() {
siehst du auf einen Blick, was bei {kita.name} ansteht.
</p>
</div>
<div className="mt-9 inline-flex shrink-0 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 py-2 text-[13px] font-semibold text-[var(--ink-soft)]">
<div className="mt-9 max-[760px]:mt-0 inline-flex shrink-0 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 py-2 text-[13px] font-semibold text-[var(--ink-soft)]">
<Clock3 className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(now, "EEE, d. MMM yyyy", { locale: de })}
</div>
</header>
<AlarmDashboardHints />
<RsvpActionCard events={rsvpEvents} />
<NewsTicker items={announcements} />
<div className="grid grid-cols-[minmax(0,1fr)_360px] gap-5 max-[1100px]:grid-cols-1">
<div className="min-w-0">
{session.user.role === UserRole.ERZIEHER && (
+12 -167
View File
@@ -1,15 +1,10 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { EventParticipationStatus } from "@prisma/client";
import { AlertTriangle } from "lucide-react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { AlertTriangle, Check, X } from "lucide-react";
import { toast } from "sonner";
import { submitEventRsvp } from "@/actions/rsvp";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { RsvpEventDto } from "@/lib/rsvp";
import { RsvpDialog } from "@/components/rsvp-form";
import {
Card,
CardContent,
@@ -17,41 +12,18 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export type DashboardRsvpEventDto = {
id: string;
title: string;
startDate: string;
rsvpDeadline: string | null;
children: {
id: string;
name: string;
status: EventParticipationStatus | null;
}[];
};
export function RsvpActionCard({ events }: { events: DashboardRsvpEventDto[] }) {
export function RsvpActionCard({ events }: { events: RsvpEventDto[] }) {
if (events.length === 0) return null;
return (
<Card className="mb-8 border-[var(--warn)] bg-[var(--warn-soft)]">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg text-[var(--ink)]">
<AlertTriangle className="h-5 w-5 text-[var(--warn)] [stroke-width:1.7]" />
Aktion erforderlich
<Card className="mb-5 border-[var(--warn)] bg-[var(--warn-soft)]">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<AlertTriangle className="h-5 w-5 text-[var(--warn)]" />
Rückmeldung erforderlich
</CardTitle>
<CardDescription className="text-[var(--ink-soft)]">
Für diese Termine fehlt noch mindestens eine Rückmeldung deiner
Familie.
<CardDescription>
Für diese Termine fehlt noch eine Antwort eurer Familie.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
@@ -61,7 +33,7 @@ export function RsvpActionCard({ events }: { events: DashboardRsvpEventDto[] })
className="flex flex-col gap-3 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p className="font-medium text-[var(--ink)]">{event.title}</p>
<p className="font-medium">{event.title}</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Rückmeldung bis{" "}
{format(
@@ -78,130 +50,3 @@ export function RsvpActionCard({ events }: { events: DashboardRsvpEventDto[] })
</Card>
);
}
function RsvpDialog({ event }: { event: DashboardRsvpEventDto }) {
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const initialSelection = useMemo(
() =>
Object.fromEntries(
event.children.map((child) => [child.id, child.status ?? ""]),
) as Record<string, EventParticipationStatus | "">,
[event.children],
);
const [selection, setSelection] = useState(initialSelection);
function submit() {
const missingChild = event.children.find((child) => !selection[child.id]);
if (missingChild) {
toast.error(`Bitte Rückmeldung für ${missingChild.name} auswählen.`);
return;
}
startTransition(async () => {
for (const child of event.children) {
const status = selection[child.id];
if (!status) continue;
const result = await submitEventRsvp({
eventId: event.id,
childId: child.id,
status,
});
if (result.error) {
toast.error(result.error);
return;
}
}
toast.success("Rückmeldung gespeichert.");
setOpen(false);
});
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (nextOpen) setSelection(initialSelection);
}}
>
<DialogTrigger asChild>
<Button size="sm">Jetzt abstimmen</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Rückmeldung für {event.title}</DialogTitle>
<DialogDescription>
Wähle pro Kind aus, ob es am Termin teilnimmt.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-2">
{event.children.map((child) => (
<div
key={child.id}
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4"
>
<div className="mb-3 flex items-center justify-between gap-3">
<p className="font-medium text-[var(--ink)]">{child.name}</p>
{child.status && (
<Badge variant="outline">bereits gemeldet</Badge>
)}
</div>
<div className="grid gap-2 sm:grid-cols-2">
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-3 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]">
<input
type="radio"
name={`rsvp-${child.id}`}
checked={
selection[child.id] ===
EventParticipationStatus.ATTENDING
}
onChange={() =>
setSelection((current) => ({
...current,
[child.id]: EventParticipationStatus.ATTENDING,
}))
}
/>
<Check className="h-4 w-4 text-[var(--good)] [stroke-width:1.7]" />
Nimmt teil
</label>
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-3 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]">
<input
type="radio"
name={`rsvp-${child.id}`}
checked={
selection[child.id] ===
EventParticipationStatus.NOT_ATTENDING
}
onChange={() =>
setSelection((current) => ({
...current,
[child.id]: EventParticipationStatus.NOT_ATTENDING,
}))
}
/>
<X className="h-4 w-4 text-[var(--danger)] [stroke-width:1.7]" />
Nimmt nicht teil
</label>
</div>
</div>
))}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
Abbrechen
</Button>
<Button type="button" disabled={isPending} onClick={submit}>
{isPending ? "Speichern..." : "Rückmeldung speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+7
View File
@@ -44,6 +44,13 @@ const navItems = [
icon: LayoutDashboard,
exact: true,
},
{
section: "Heute",
href: "/dashboard/mitteilungen",
label: "Mitteilungen",
icon: Megaphone,
exact: false,
},
{
section: "Heute",
href: "/dashboard/notdienst",
+223
View File
@@ -0,0 +1,223 @@
"use client";
import { useId, useState, useTransition } from "react";
import { EventParticipationStatus, RsvpMode } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { toast } from "sonner";
import { submitEventRsvp } from "@/actions/rsvp";
import { hasMissingRsvp, type RsvpEventDto } from "@/lib/rsvp";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export function RsvpDialog({ event }: { event: RsvpEventDto }) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">
{!event.canReply
? "Rückmeldung ansehen"
: hasMissingRsvp(event)
? "Jetzt antworten"
: "Antwort ändern"}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Rückmeldung für {event.title}</DialogTitle>
<DialogDescription>
{event.mode === RsvpMode.FAMILY
? "Eine gemeinsame Antwort für eure Familie."
: "Eine Rückmeldung für jedes eigene Kind."}
</DialogDescription>
</DialogHeader>
<RsvpForm
key={JSON.stringify(event)}
event={event}
onSaved={() => setOpen(false)}
/>
</DialogContent>
</Dialog>
);
}
export function RsvpForm({
event,
onSaved,
}: {
event: RsvpEventDto;
onSaved?: () => void;
}) {
const id = useId();
const [isPending, startTransition] = useTransition();
const [familyStatus, setFamilyStatus] = useState<
EventParticipationStatus | ""
>(event.familyResponse?.status ?? "");
const [adultCount, setAdultCount] = useState(
String(event.familyResponse?.adultCount || 1),
);
const [selection, setSelection] = useState<
Record<string, EventParticipationStatus | "">
>(
Object.fromEntries(
event.children.map((child) => [child.id, child.status ?? ""]),
),
);
const isFamily = event.mode === RsvpMode.FAMILY;
const targets = isFamily
? [{ id: "family", name: "Eure Familie", status: familyStatus }]
: event.children.map((child) => ({
...child,
status: selection[child.id],
}));
return (
<form
className="grid gap-4"
onSubmit={(e) => {
e.preventDefault();
if (!event.canReply || isPending) return;
if (targets.some((target) => !target.status)) {
toast.error("Bitte für alle eine Antwort auswählen.");
return;
}
const count =
familyStatus === EventParticipationStatus.ATTENDING
? Number(adultCount)
: 0;
if (
isFamily &&
(!Number.isSafeInteger(count) ||
count <
(familyStatus === EventParticipationStatus.ATTENDING ? 1 : 0))
) {
toast.error("Bitte eine gültige Anzahl Erwachsener angeben.");
return;
}
startTransition(async () => {
const result = await submitEventRsvp(
isFamily
? {
eventId: event.id,
mode: RsvpMode.FAMILY,
status: familyStatus,
adultCount: count,
}
: {
eventId: event.id,
mode: RsvpMode.CHILD,
responses: event.children.map((child) => ({
childId: child.id,
status: selection[child.id],
})),
},
);
if (result.error) {
toast.error(result.error);
return;
}
toast.success("Rückmeldung gespeichert.");
onSaved?.();
});
}}
>
<p className="text-sm text-[var(--ink-soft)]">
{isFamily ? "Antwort pro Familie" : "Antwort pro Kind"} · Rückmeldung
bis{" "}
{format(
new Date(event.rsvpDeadline ?? event.startDate),
"dd.MM.yyyy HH:mm",
{ locale: de },
)}
</p>
<fieldset
disabled={isPending || !event.canReply}
className="grid gap-3 disabled:opacity-75"
>
<legend className="sr-only">Teilnahme auswählen</legend>
{targets.map((target) => (
<fieldset
key={target.id}
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4"
>
<legend className="px-1 text-sm font-medium text-[var(--ink)]">
{target.name}
</legend>
<div className="grid gap-2 sm:grid-cols-2">
{(
[
EventParticipationStatus.ATTENDING,
EventParticipationStatus.NOT_ATTENDING,
] as const
).map((status) => (
<label
key={status}
className="flex cursor-pointer items-center gap-2 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-3 text-sm"
>
<input
type="radio"
name={`${id}-${target.id}`}
value={status}
required
checked={target.status === status}
onChange={() =>
isFamily
? setFamilyStatus(status)
: setSelection((current) => ({
...current,
[target.id]: status,
}))
}
/>
{status === EventParticipationStatus.ATTENDING
? "Nimmt teil"
: "Nimmt nicht teil"}
</label>
))}
</div>
</fieldset>
))}
{isFamily && familyStatus === EventParticipationStatus.ATTENDING ? (
<div className="grid gap-2">
<Label htmlFor={`${id}-adults`}>
Anzahl teilnehmender Erwachsener
</Label>
<Input
id={`${id}-adults`}
type="number"
min={1}
step={1}
required
value={adultCount}
onChange={(e) => setAdultCount(e.target.value)}
/>
</div>
) : null}
</fieldset>
<p className="text-xs text-[var(--ink-soft)]">
Die Antwort gilt für euren gemeinsamen Haushalt und ist für beide
Elternkonten sichtbar.
</p>
{event.canReply ? (
<Button type="submit" disabled={isPending}>
{isPending ? "Speichern…" : "Rückmeldung speichern"}
</Button>
) : (
<p className="text-sm font-medium text-[var(--ink-muted)]">
Die Rückmeldefrist ist abgelaufen. Gespeicherte Antworten bleiben
sichtbar.
</p>
)}
</form>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { AnnouncementAudience, UserRole } from "@prisma/client";
// Admins need access to all publications for management. Other roles only
// receive their audience; a parent with a coordination role is still a parent.
export function visibleAnnouncementAudiences(user: {
role: UserRole;
familyId?: string | null;
}): AnnouncementAudience[] {
if (user.role === UserRole.ADMIN) return Object.values(AnnouncementAudience);
const audiences: AnnouncementAudience[] = [AnnouncementAudience.ALLE];
if (user.role === UserRole.ERZIEHER)
audiences.push(AnnouncementAudience.ERZIEHER);
if (
user.role !== UserRole.ERZIEHER &&
(user.familyId || user.role === UserRole.ELTERN)
) {
audiences.push(AnnouncementAudience.ELTERN);
}
return audiences;
}
export function announcementRecipientWhere(audience: AnnouncementAudience) {
const parents = {
role: { not: UserRole.ERZIEHER },
OR: [{ familyId: { not: null } }, { role: UserRole.ELTERN }],
};
if (audience === AnnouncementAudience.ELTERN) return parents;
if (audience === AnnouncementAudience.ERZIEHER)
return { role: UserRole.ERZIEHER };
return { OR: [parents, { role: UserRole.ERZIEHER }] };
}
export const announcementAudienceLabels: Record<AnnouncementAudience, string> =
{
ELTERN: "Eltern",
ERZIEHER: "ErzieherInnen",
ALLE: "Eltern und ErzieherInnen",
};
+48
View File
@@ -0,0 +1,48 @@
import "server-only";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import type { KitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { visibleAnnouncementAudiences } from "@/lib/announcement-access";
export async function getAnnouncements(
session: KitaSession,
options: { take: number; skip?: number },
) {
const rows = await prisma.announcement.findMany({
where: {
kitaId: session.user.kitaId,
audience: { in: visibleAnnouncementAudiences(session.user) },
},
select: {
id: true,
title: true,
content: true,
createdAt: true,
author: { select: { firstName: true, lastName: true } },
reads: { where: { userId: session.user.id }, select: { id: true } },
attachments: {
select: { id: true, fileName: true, fileUrl: true, fileType: true },
},
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: options.take,
skip: options.skip,
});
return rows.map((row) => ({
id: row.id,
title: row.title,
content: row.content,
excerpt: row.content
.replace(/^#{1,6}\s+/gm, "")
.replace(/!?(\[([^\]]+)\])\([^)]+\)/g, "$2")
.replace(/(\*\*|__|`)/g, "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 300),
createdAt: format(row.createdAt, "dd.MM.yyyy HH:mm", { locale: de }),
authorName: `${row.author.firstName} ${row.author.lastName}`,
isUnread: row.reads.length === 0,
attachments: row.attachments,
}));
}
+10
View File
@@ -0,0 +1,10 @@
import type { Prisma } from "@prisma/client";
// Serialize RSVP writes and changes of the response mode on the same event.
export async function lockEvent(
tx: Prisma.TransactionClient,
eventId: string,
kitaId: string,
) {
await tx.$queryRaw`SELECT id FROM termine WHERE id = ${eventId} AND "kitaId" = ${kitaId} FOR UPDATE`;
}
+77
View File
@@ -0,0 +1,77 @@
import "server-only";
import { RsvpMode, TerminStatus, UserRole } from "@prisma/client";
import type { KitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { hasMissingRsvp, type RsvpEventDto } from "@/lib/rsvp";
export async function getRsvpEvents(
session: KitaSession,
options: { onlyOpen?: boolean } = {},
): Promise<RsvpEventDto[]> {
if (!session.user.familyId || session.user.role === UserRole.ERZIEHER)
return [];
const now = new Date();
const familyId = session.user.familyId;
const [events, children] = await Promise.all([
prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
requiresRsvp: true,
status: TerminStatus.CONFIRMED,
kita: { terminModuleEnabled: true },
...(options.onlyOpen
? {
startDate: { gt: now },
OR: [{ rsvpDeadline: null }, { rsvpDeadline: { gt: now } }],
}
: {}),
},
select: {
id: true,
title: true,
startDate: true,
rsvpDeadline: true,
rsvpMode: true,
participations: {
where: { child: { familyId, active: true } },
select: { childId: true, status: true },
},
familyParticipations: {
where: { familyId },
select: { status: true, adultCount: true },
},
},
orderBy: { startDate: "asc" },
}),
prisma.child.findMany({
where: { kitaId: session.user.kitaId, familyId, active: true },
select: { id: true, firstName: true, lastName: true },
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
}),
]);
const rows: RsvpEventDto[] = events
.filter(
(event) => event.rsvpMode === RsvpMode.FAMILY || children.length > 0,
)
.map((event) => ({
id: event.id,
title: event.title,
startDate: event.startDate.toISOString(),
rsvpDeadline: event.rsvpDeadline?.toISOString() ?? null,
mode: event.rsvpMode,
canReply:
(event.rsvpDeadline ?? event.startDate) > now && event.startDate > now,
familyResponse: event.familyParticipations[0] ?? null,
children:
event.rsvpMode === RsvpMode.CHILD
? children.map((child) => ({
id: child.id,
name: `${child.firstName} ${child.lastName}`,
status:
event.participations.find((row) => row.childId === child.id)
?.status ?? null,
}))
: [],
}));
return options.onlyOpen ? rows.filter(hasMissingRsvp) : rows;
}
+70
View File
@@ -0,0 +1,70 @@
import { EventParticipationStatus, RsvpMode, TerminType } from "@prisma/client";
import { z } from "zod";
export const rsvpSubmissionSchema = z.discriminatedUnion("mode", [
z.object({
eventId: z.string().min(1),
mode: z.literal(RsvpMode.CHILD),
responses: z
.array(
z.object({
childId: z.string().min(1),
status: z.nativeEnum(EventParticipationStatus),
}),
)
.min(1)
.refine(
(rows) => new Set(rows.map((row) => row.childId)).size === rows.length,
"Doppelte Kinderantwort",
),
}),
z
.object({
eventId: z.string().min(1),
mode: z.literal(RsvpMode.FAMILY),
status: z.nativeEnum(EventParticipationStatus),
adultCount: z.number().int().min(0).max(2147483647),
})
.refine(
(row) =>
row.status === EventParticipationStatus.ATTENDING
? row.adultCount >= 1
: row.adultCount === 0,
"Bei Zusage bitte die Anzahl Erwachsener angeben; bei Absage muss sie null sein.",
),
]);
export function suggestedRsvpMode(type: TerminType): RsvpMode {
return new Set<TerminType>([
TerminType.ELTERNABEND,
TerminType.MITGLIEDERVERSAMMLUNG,
TerminType.ELTERNCAFE,
TerminType.KITA_FEST,
]).has(type)
? RsvpMode.FAMILY
: RsvpMode.CHILD;
}
export type RsvpEventDto = {
id: string;
title: string;
startDate: string;
rsvpDeadline: string | null;
mode: RsvpMode;
canReply: boolean;
familyResponse: {
status: EventParticipationStatus;
adultCount: number;
} | null;
children: {
id: string;
name: string;
status: EventParticipationStatus | null;
}[];
};
export function hasMissingRsvp(event: RsvpEventDto) {
return event.mode === RsvpMode.FAMILY
? !event.familyResponse
: event.children.some((child) => !child.status);
}