Add RSVP events and Unsplash image support
This commit is contained in:
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user