Files
kita-planer/src/app/dashboard/admin/kalender/admin-calendar-manager.tsx
T

722 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMemo, useState, useTransition } from "react";
import {
EventParticipationStatus,
RsvpMode,
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 { suggestedRsvpMode } from "@/lib/rsvp";
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;
rsvpMode: RsvpMode;
familyParticipations: {
familyId: string;
status: EventParticipationStatus;
adultCount: number;
}[];
rsvpDeadline: string | null;
participantInfo: string | null;
participations: {
childId: string;
status: EventParticipationStatus;
}[];
};
type ChildDto = {
id: string;
name: string;
familyName: string;
};
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(
(termin) => termin.status === TerminStatus.PENDING,
).length;
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
ADMIN · EVENT-ANMELDUNGEN
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Termine{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
koordinieren
</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 Familie oder
Kind verfolgen.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
{termine.length} Termine
</span>
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
{rsvpCount} RSVP
</span>
{pendingCount > 0 && (
<span className="inline-flex h-9 items-center rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-3 text-[13px] font-semibold text-[var(--warn)]">
{pendingCount} ausstehend
</span>
)}
<TerminFormDialog mode="create" />
</div>
</header>
{termine.length === 0 ? (
<Card className="border-dashed">
<div className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<CalendarPlus className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h2 className="mt-4 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Noch keine Termine
</h2>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
</p>
</div>
</Card>
) : (
<div className="grid gap-4">
{termine.map((termin) => (
<AdminTerminCard
key={termin.id}
termin={termin}
childRows={childRows}
familyRows={familyRows}
/>
))}
</div>
)}
</div>
);
}
function AdminTerminCard({
termin,
childRows,
familyRows,
}: {
termin: AdminTerminDto;
childRows: ChildDto[];
familyRows: { id: string; name: string }[];
}) {
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">
<CardHeader className="border-b border-[var(--hairline-soft)] bg-[var(--surface-2)]">
<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">
Antwort pro {isFamily ? "Familie" : "Kind"}
</Badge>
)}
</div>
<CardTitle className="text-lg">{termin.title}</CardTitle>
<CardDescription className="mt-1 flex flex-wrap items-center gap-2 text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{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-[var(--ink-soft)]">
{termin.description}
</p>
)}
{termin.requiresRsvp ? (
<div className="grid gap-4">
<div className="flex flex-col gap-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-[var(--ink)] sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium">
{stats.attending} Zugesagt / {stats.notAttending} Abgesagt /{" "}
{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,
},
)
: "bis Terminbeginn"}
</p>
</div>
<Users className="h-5 w-5 shrink-0 text-[var(--warn)] [stroke-width:1.7]" />
</div>
{termin.participantInfo && (
<div className="rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-sm text-[var(--ink)]">
<span className="font-medium">Hinweis für Teilnehmer: </span>
{termin.participantInfo}
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>{isFamily ? "Familie" : "Kind"}</TableHead>
<TableHead>{isFamily ? "Erwachsene" : "Familie"}</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((child) => {
const status = stats.statusByChildId.get(child.id) ?? null;
return (
<TableRow key={child.id}>
<TableCell className="font-medium">
{child.name}
</TableCell>
<TableCell>
{isFamily
? (stats.adultCounts.get(child.id) ?? "–")
: child.familyName}
</TableCell>
<TableCell>
<RsvpStatusBadge status={status} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
) : (
<div className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm italic text-[var(--ink-faint)]">
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 [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) {
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,
rsvpMode,
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);
setRsvpMode(
termin?.rsvpMode ?? suggestedRsvpMode(TerminType.KITA_FEST),
);
setModeTouched(false);
}
}}
>
<DialogTrigger asChild>
<Button variant={mode === "edit" ? "pill" : "default"} size="sm">
{mode === "edit" ? (
<Pencil className="h-4 w-4 [stroke-width:1.7]" />
) : (
<CalendarPlus className="h-4 w-4 [stroke-width:1.7]" />
)}
{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"
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
>
{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-lg border border-[var(--hairline)] bg-[var(--surface-2)] 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-[var(--hairline)] accent-[var(--accent-deep)]"
/>
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-[var(--hairline)] accent-[var(--accent-deep)]"
/>
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-[var(--hairline)] accent-[var(--accent-deep)]"
/>
Teilnahme-Abfrage (RSVP) aktivieren
</label>
</div>
{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}-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"
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-[var(--ink-muted)]">
<HelpCircle className="h-3.5 w-3.5" />
Ausstehend
</Badge>
);
}
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;
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: rows.length - attending - notAttending,
statusByChildId,
adultCounts,
adultCount,
};
}
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-[var(--warn-soft)] text-[var(--warn)]";
case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG:
return "border-transparent bg-[var(--danger-soft)] text-[var(--danger)]";
case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN:
return "border-transparent bg-[oklch(0.94_0.04_280)] text-[oklch(0.48_0.08_280)]";
case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "border-transparent bg-[var(--accent-soft)] text-[var(--accent-deep)]";
default:
return "border-[var(--hairline)] bg-[var(--surface-2)] text-[var(--ink-soft)]";
}
}
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";
}
}