722 lines
24 KiB
TypeScript
722 lines
24 KiB
TypeScript
"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";
|
||
}
|
||
}
|