feat: add parent announcements and family or child event responses
This commit is contained in:
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 “{termin.title}” wirklich löschen? Alle
|
||||
zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren.
|
||||
Möchtest du den Termin “{termin.title}” 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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user