feat: add parent announcements and family or child event responses

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