208 lines
6.7 KiB
TypeScript
208 lines
6.7 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState, useTransition } from "react";
|
|
import { EventParticipationStatus } from "@prisma/client";
|
|
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 {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
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[] }) {
|
|
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
|
|
</CardTitle>
|
|
<CardDescription className="text-[var(--ink-soft)]">
|
|
Für diese Termine fehlt noch mindestens eine Rückmeldung deiner
|
|
Familie.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-3">
|
|
{events.map((event) => (
|
|
<div
|
|
key={event.id}
|
|
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="mt-1 text-sm text-[var(--ink-soft)]">
|
|
Rückmeldung bis{" "}
|
|
{format(
|
|
new Date(event.rsvpDeadline ?? event.startDate),
|
|
"dd.MM.yyyy HH:mm",
|
|
{ locale: de },
|
|
)}
|
|
</p>
|
|
</div>
|
|
<RsvpDialog event={event} />
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</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>
|
|
);
|
|
}
|