Add RSVP events and Unsplash image support

This commit is contained in:
t.indorf
2026-05-15 21:28:36 +02:00
parent ed6786b6ea
commit 9542def530
13 changed files with 1392 additions and 34 deletions
@@ -0,0 +1,608 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import {
EventParticipationStatus,
TerminStatus,
TerminType,
} from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import {
CalendarPlus,
Check,
Clock,
HelpCircle,
Pencil,
Users,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import {
createTerminAdmin,
updateTerminAdmin,
} from "../../kalender/actions";
type AdminTerminDto = {
id: string;
title: string;
description: string | null;
type: TerminType;
status: TerminStatus;
startDate: string;
endDate: string;
allDay: boolean;
mitbringselListEnabled: boolean;
requiresRsvp: boolean;
rsvpDeadline: string | null;
participantInfo: string | null;
participations: {
childId: string;
status: EventParticipationStatus;
}[];
};
type ChildDto = {
id: string;
name: string;
familyName: string;
};
export function AdminCalendarManager({
termine,
childRows,
}: {
termine: AdminTerminDto[];
childRows: ChildDto[];
}) {
return (
<div className="flex h-full flex-col gap-6 p-6">
<div className="flex flex-col justify-between gap-4 lg:flex-row lg:items-center">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Event-Anmeldungen & Ausflüge
</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Termine verwalten, RSVP aktivieren und den Rücklauf pro Kind
verfolgen.
</p>
</div>
<TerminFormDialog mode="create" />
</div>
{termine.length === 0 ? (
<div className="rounded-md border border-dashed p-10 text-center">
<CalendarPlus className="mx-auto h-10 w-10 text-muted-foreground" />
<h2 className="mt-4 text-lg font-semibold">Noch keine Termine</h2>
<p className="mt-1 text-sm text-muted-foreground">
Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
</p>
</div>
) : (
<div className="grid gap-4">
{termine.map((termin) => (
<AdminTerminCard
key={termin.id}
termin={termin}
childRows={childRows}
/>
))}
</div>
)}
</div>
);
}
function AdminTerminCard({
termin,
childRows,
}: {
termin: AdminTerminDto;
childRows: ChildDto[];
}) {
const stats = useMemo(() => getStats(termin, childRows), [termin, childRows]);
return (
<Card className="overflow-hidden">
<CardHeader className="border-b bg-muted/30">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="mb-2 flex flex-wrap gap-2">
<Badge className={getTypeBadgeClass(termin.type)}>
{getTypeLabel(termin.type)}
</Badge>
<Badge variant={getStatusVariant(termin.status)}>
{getStatusLabel(termin.status)}
</Badge>
{termin.requiresRsvp && (
<Badge variant="warning">RSVP aktiv</Badge>
)}
</div>
<CardTitle className="text-lg">{termin.title}</CardTitle>
<CardDescription className="mt-1 flex flex-wrap items-center gap-2">
<Clock className="h-3.5 w-3.5" />
{format(new Date(termin.startDate), "dd.MM.yyyy", {
locale: de,
})}
{!termin.allDay && (
<>
<span>·</span>
<span>
{format(new Date(termin.startDate), "HH:mm", {
locale: de,
})}{" "}
bis{" "}
{format(new Date(termin.endDate), "HH:mm", {
locale: de,
})}
</span>
</>
)}
</CardDescription>
</div>
<TerminFormDialog mode="edit" termin={termin} />
</div>
</CardHeader>
<CardContent className="grid gap-5 pt-5">
{termin.description && (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
{termin.description}
</p>
)}
{termin.requiresRsvp ? (
<div className="grid gap-4">
<div className="flex flex-col gap-3 rounded-md border bg-amber-50/70 p-4 text-amber-950 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium">
{stats.attending} Zugesagt / {stats.notAttending} Abgesagt /{" "}
{stats.pending} Ausstehend
</p>
<p className="mt-1 text-sm text-amber-900/80">
Frist:{" "}
{termin.rsvpDeadline
? format(new Date(termin.rsvpDeadline), "dd.MM.yyyy HH:mm", {
locale: de,
})
: "bis Terminbeginn"}
</p>
</div>
<Users className="h-5 w-5 shrink-0" />
</div>
{termin.participantInfo && (
<div className="rounded-md border border-yellow-300 bg-yellow-50 p-4 text-sm text-yellow-950">
<span className="font-medium">Hinweis für Teilnehmer: </span>
{termin.participantInfo}
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>Kind</TableHead>
<TableHead>Familie</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{childRows.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>
<RsvpStatusBadge status={status} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
) : (
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
Für diesen Termin ist keine Teilnahme-Abfrage aktiv.
</div>
)}
</CardContent>
</Card>
);
}
function TerminFormDialog({
mode,
termin,
}: {
mode: "create" | "edit";
termin?: AdminTerminDto;
}) {
const [open, setOpen] = useState(false);
const [requiresRsvp, setRequiresRsvp] = useState(
termin?.requiresRsvp ?? false,
);
const [isPending, startTransition] = useTransition();
function handleAction(formData: FormData) {
const startDate = new Date(String(formData.get("startDate")));
const endDate = new Date(String(formData.get("endDate")));
const deadlineRaw = String(formData.get("rsvpDeadline") ?? "");
const hasRsvp = formData.get("requiresRsvp") === "on";
const payload = {
title: String(formData.get("title") ?? ""),
description: String(formData.get("description") ?? ""),
type: formData.get("type") as TerminType,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
allDay: formData.get("allDay") === "on",
mitbringselListEnabled: formData.get("mitbringselListEnabled") === "on",
requiresRsvp: hasRsvp,
rsvpDeadline:
hasRsvp && deadlineRaw ? new Date(deadlineRaw).toISOString() : null,
participantInfo: String(formData.get("participantInfo") ?? ""),
};
startTransition(async () => {
const result =
mode === "edit" && termin
? await updateTerminAdmin(termin.id, payload)
: await createTerminAdmin(payload);
if (result.error) {
toast.error(result.error);
return;
}
toast.success(
mode === "edit" ? "Termin aktualisiert." : "Termin angelegt.",
);
setOpen(false);
});
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (nextOpen) {
setRequiresRsvp(termin?.requiresRsvp ?? false);
}
}}
>
<DialogTrigger asChild>
<Button variant={mode === "edit" ? "outline" : "default"} size="sm">
{mode === "edit" ? (
<Pencil className="h-4 w-4" />
) : (
<CalendarPlus className="h-4 w-4" />
)}
{mode === "edit" ? "Bearbeiten" : "Termin anlegen"}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<form action={handleAction}>
<DialogHeader>
<DialogTitle>
{mode === "edit" ? "Termin bearbeiten" : "Termin anlegen"}
</DialogTitle>
<DialogDescription>
RSVP kann für Ausflüge und besondere Events aktiviert werden.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor={`${mode}-title`}>Titel</Label>
<Input
id={`${mode}-title`}
name="title"
defaultValue={termin?.title ?? ""}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-description`}>Beschreibung</Label>
<Textarea
id={`${mode}-description`}
name="description"
defaultValue={termin?.description ?? ""}
rows={3}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-type`}>Kategorie</Label>
<select
id={`${mode}-type`}
name="type"
defaultValue={termin?.type ?? TerminType.KITA_FEST}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
required
>
{Object.values(TerminType).map((type) => (
<option key={type} value={type}>
{getTypeLabel(type)}
</option>
))}
</select>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor={`${mode}-startDate`}>Start</Label>
<Input
id={`${mode}-startDate`}
name="startDate"
type="datetime-local"
defaultValue={
termin ? toDatetimeLocalValue(termin.startDate) : ""
}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-endDate`}>Ende</Label>
<Input
id={`${mode}-endDate`}
name="endDate"
type="datetime-local"
defaultValue={
termin ? toDatetimeLocalValue(termin.endDate) : ""
}
required
/>
</div>
</div>
<div className="grid gap-3 rounded-md border p-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="allDay"
defaultChecked={termin?.allDay ?? false}
className="h-4 w-4 rounded border-input"
/>
Ganztägiger Termin
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="mitbringselListEnabled"
defaultChecked={termin?.mitbringselListEnabled ?? false}
className="h-4 w-4 rounded border-input"
/>
Mitbring-Liste aktiv
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="requiresRsvp"
checked={requiresRsvp}
onChange={(event) => setRequiresRsvp(event.target.checked)}
className="h-4 w-4 rounded border-input"
/>
Teilnahme-Abfrage (RSVP) aktivieren
</label>
</div>
{requiresRsvp && (
<div className="grid gap-4 rounded-md border border-amber-200 bg-amber-50/60 p-4">
<div className="grid gap-2">
<Label htmlFor={`${mode}-rsvpDeadline`}>
Rückmeldefrist
</Label>
<Input
id={`${mode}-rsvpDeadline`}
name="rsvpDeadline"
type="datetime-local"
defaultValue={
termin?.rsvpDeadline
? toDatetimeLocalValue(termin.rsvpDeadline)
: ""
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-participantInfo`}>
Hinweise für Teilnehmer
</Label>
<Textarea
id={`${mode}-participantInfo`}
name="participantInfo"
defaultValue={termin?.participantInfo ?? ""}
placeholder="z.B. Zweite Brotdose mitbringen"
rows={3}
/>
</div>
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Speichern..." : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function RsvpStatusBadge({
status,
}: {
status: EventParticipationStatus | null;
}) {
if (status === EventParticipationStatus.ATTENDING) {
return (
<Badge variant="success" className="gap-1">
<Check className="h-3.5 w-3.5" />
Zugesagt
</Badge>
);
}
if (status === EventParticipationStatus.NOT_ATTENDING) {
return (
<Badge variant="destructive" className="gap-1">
<X className="h-3.5 w-3.5" />
Abgesagt
</Badge>
);
}
return (
<Badge variant="outline" className="gap-1 text-muted-foreground">
<HelpCircle className="h-3.5 w-3.5" />
Ausstehend
</Badge>
);
}
function getStats(termin: AdminTerminDto, children: ChildDto[]) {
const statusByChildId = new Map(
termin.participations.map((participation) => [
participation.childId,
participation.status,
]),
);
let attending = 0;
let notAttending = 0;
children.forEach((child) => {
const status = statusByChildId.get(child.id);
if (status === EventParticipationStatus.ATTENDING) attending += 1;
if (status === EventParticipationStatus.NOT_ATTENDING) notAttending += 1;
});
return {
attending,
notAttending,
pending: Math.max(children.length - attending - notAttending, 0),
statusByChildId,
};
}
function toDatetimeLocalValue(value: string) {
const date = new Date(value);
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return localDate.toISOString().slice(0, 16);
}
function getStatusVariant(
status: TerminStatus,
): "default" | "secondary" | "destructive" | "outline" | "success" | "warning" {
switch (status) {
case TerminStatus.CONFIRMED:
return "success";
case TerminStatus.PENDING:
return "warning";
case TerminStatus.REJECTED:
case TerminStatus.CANCELLED:
return "destructive";
default:
return "outline";
}
}
function getStatusLabel(status: TerminStatus) {
switch (status) {
case TerminStatus.CONFIRMED:
return "Bestätigt";
case TerminStatus.PENDING:
return "Ausstehend";
case TerminStatus.REJECTED:
return "Abgelehnt";
case TerminStatus.CANCELLED:
return "Abgesagt";
}
}
function getTypeBadgeClass(type: TerminType) {
switch (type) {
case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG:
return "border-transparent bg-amber-500 text-white";
case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG:
return "border-transparent bg-rose-500 text-white";
case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN:
return "border-transparent bg-blue-500 text-white";
case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "border-transparent bg-purple-500 text-white";
default:
return "border-slate-300 bg-slate-100 text-slate-800";
}
}
function getTypeLabel(type: TerminType) {
switch (type) {
case TerminType.KITA_FEST:
return "Kita-Fest";
case TerminType.SCHLIESSTAG:
return "Schließtag";
case TerminType.TEAMTAG:
return "Teamtag";
case TerminType.MITMACH_TAG:
return "Mitmach-Tag";
case TerminType.ELTERNABEND:
return "Elternabend";
case TerminType.MITGLIEDERVERSAMMLUNG:
return "Mitgliederversammlung";
case TerminType.ELTERNCAFE:
return "Elterncafe";
case TerminType.GEBURTSTAG_INTERN:
return "Geburtstag Intern";
case TerminType.GEBURTSTAG_EXTERN:
return "Raumanfrage Extern";
case TerminType.SONSTIGES:
return "Sonstiges";
}
}