Files
kita-planer/src/app/dashboard/kalender/_components/termin-list.tsx
T

779 lines
25 KiB
TypeScript

"use client";
import type { ReactNode } from "react";
import { useState, useTransition } from "react";
import {
DutyAssignmentStatus,
RsvpMode,
TerminStatus,
TerminType,
} from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import {
AlertTriangle,
Calendar,
Clock,
MapPin,
MoreHorizontal,
WashingMachine,
} from "lucide-react";
import { toast } from "sonner";
import { RsvpForm } from "@/components/rsvp-form";
import type { RsvpEventDto } from "@/lib/rsvp";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from "@/components/ui/dialog";
import { AdminTerminModal } from "./admin-termin-modal";
import { TerminRequestModal } from "./termin-request-modal";
import { MitbringselList } from "./mitbringsel-list";
import { createNotdienstSwapRequest, deleteTerminAdmin } from "../actions";
export type TerminListItemDto = {
kind: "termin";
id: string;
title: string;
description: string | null;
type: TerminType;
status: TerminStatus;
startDate: Date;
endDate: Date;
allDay: boolean;
mitbringselListEnabled: boolean;
requiresRsvp: boolean;
rsvpMode: RsvpMode;
rsvpDeadline: Date | null;
hasRsvpResponses: boolean;
rsvpEvent?: RsvpEventDto;
participantInfo: string | null;
showParticipantInfo: boolean;
mitbringselItems?: {
id: string;
userId: string;
content: string;
user: { firstName: string; lastName: string };
}[];
};
export type DutyCalendarItemDto = {
kind: "duty";
id: string;
title: string;
familyName: string;
status: DutyAssignmentStatus;
startDate: Date;
endDate: Date;
isOwn: boolean;
};
export type NotdienstCalendarItemDto = {
kind: "notdienst";
id: string;
planId: string;
planYear: number;
planMonth: number;
title: string;
familyId: string;
familyName: string;
childName: string;
isOwn: boolean;
canRequestSwap: boolean;
startDate: Date;
endDate: Date;
};
export type NotdienstSwapOptionDto = {
id: string;
date: Date;
childName: string;
familyName: string;
};
export type CalendarListItemDto =
| TerminListItemDto
| DutyCalendarItemDto
| NotdienstCalendarItemDto;
export function TerminList({
termine,
userId,
isAdmin,
notdienstSwapOptions = [],
}: {
termine: CalendarListItemDto[];
userId: string;
isAdmin: boolean;
notdienstSwapOptions?: NotdienstSwapOptionDto[];
}) {
if (termine.length === 0) {
return (
<Card>
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Calendar className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h3 className="mt-5 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Keine Termine in diesem Zeitraum
</h3>
<p className="mt-2 max-w-md text-sm leading-6 text-[var(--ink-soft)]">
Lege den ersten Termin an, damit Eltern planen können.
</p>
<div className="mt-5">
{isAdmin ? (
<AdminTerminModal
triggerLabel="+ Termin anlegen"
triggerClassName=""
/>
) : (
<TerminRequestModal
triggerLabel="+ Termin anlegen"
triggerVariant="default"
triggerClassName=""
/>
)}
</div>
</CardContent>
</Card>
);
}
const groups = groupByMonth(termine);
return (
<div className="space-y-8">
{groups.map((group) => (
<section key={group.key}>
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
{group.label}
</h2>
<span className="rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-[var(--ink-soft)] tabular-nums">
{group.items.length}{" "}
{group.items.length === 1 ? "Termin" : "Termine"}
</span>
</div>
<Card className="overflow-hidden p-0">
<div className="divide-y divide-[var(--hairline-soft)]">
{group.items.map((item) =>
item.kind === "duty" ? (
<DutyRow
key={`duty-${item.id}`}
duty={item}
isAdmin={isAdmin}
/>
) : item.kind === "notdienst" ? (
<NotdienstRow
key={`notdienst-${item.id}`}
notdienst={item}
isAdmin={isAdmin}
notdienstSwapOptions={notdienstSwapOptions}
/>
) : (
<TerminRow
key={`termin-${item.id}`}
termin={item}
userId={userId}
isAdmin={isAdmin}
/>
),
)}
</div>
</Card>
</section>
))}
</div>
);
}
function DutyRow({
duty,
isAdmin,
}: {
duty: DutyCalendarItemDto;
isAdmin: boolean;
}) {
const color = duty.isOwn ? "oklch(0.60 0.16 160)" : "oklch(0.75 0.08 160)";
const [menuOpen, setMenuOpen] = useState(false);
return (
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
<div
className="absolute inset-y-3 left-0 w-[3px] rounded-r-full"
style={{ backgroundColor: color }}
/>
<DateTile date={duty.startDate} />
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
{duty.title}
</h3>
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(duty.startDate, "dd.MM.", { locale: de })} -{" "}
{format(duty.endDate, "dd.MM.yyyy", { locale: de })}
<WashingMachine className="ml-1 h-3.5 w-3.5 [stroke-width:1.7]" />
{duty.familyName}
</p>
</div>
<div className="flex items-center gap-2 relative">
<CategoryPill color={color}>
{duty.isOwn ? "Mein Elterndienst" : "Elterndienst"}
</CategoryPill>
{isAdmin ? (
<div className="relative">
<Button
variant="ghost"
size="icon"
aria-label="Mehr Optionen"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
>
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
{menuOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<a
href="/dashboard/admin/dienste"
className="flex w-full items-center rounded-lg px-2.5 py-2 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]"
onClick={() => setMenuOpen(false)}
>
Dienst im Planer bearbeiten
</a>
</div>
</>
)}
</div>
) : (
<div className="w-9 h-9" />
)}
</div>
</div>
);
}
function TerminRow({
termin,
userId,
isAdmin,
}: {
termin: TerminListItemDto;
userId: string;
isAdmin: boolean;
}) {
const color = getTypeColor(termin.type);
const [menuOpen, setMenuOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [isDeleting, startDeleteTransition] = useTransition();
const handleDeleteConfirm = () => {
startDeleteTransition(async () => {
const res = await deleteTerminAdmin(termin.id);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Termin wurde erfolgreich gelöscht.");
setDeleteOpen(false);
}
});
};
return (
<>
<Dialog>
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
<div
className="absolute inset-y-3 left-0 w-[3px] rounded-r-full"
style={{ backgroundColor: color }}
/>
<DateTile date={termin.startDate} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
{termin.title}
</h3>
{termin.status === TerminStatus.PENDING && (
<Badge variant="warning">Ausstehend</Badge>
)}
</div>
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{formatTerminTime(termin)}
<MapPin className="ml-1 h-3.5 w-3.5 [stroke-width:1.7]" />
Kita
</p>
</div>
<div className="flex items-center gap-2 relative">
<CategoryPill color={color}>
{getTypeLabel(termin.type)}
</CategoryPill>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
Details
</Button>
</DialogTrigger>
{isAdmin ? (
<div className="relative">
<Button
variant="ghost"
size="icon"
aria-label="Mehr Optionen"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
>
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
{menuOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 mt-1 w-48 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<AdminTerminModal
existingTermin={termin}
triggerLabel="Bearbeiten"
triggerVariant="ghost"
triggerClassName="flex w-full items-center justify-start rounded-lg px-2.5 py-2 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)] font-normal h-auto bg-transparent border-0"
/>
<button
type="button"
className="flex w-full items-center rounded-lg px-2.5 py-2 text-sm text-[var(--danger)] hover:bg-[var(--danger-soft)] text-left font-normal"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
setDeleteOpen(true);
}}
>
Löschen
</button>
</div>
</>
)}
</div>
) : (
<div className="w-9 h-9" />
)}
</div>
</div>
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{termin.title}</DialogTitle>
<DialogDescription>
{format(termin.startDate, "PP", { locale: de })}
{!termin.allDay &&
` · ${format(termin.startDate, "p", { locale: de })} - ${format(
termin.endDate,
"p",
{ locale: de },
)}`}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="flex">
<CategoryPill color={color}>
{getTypeLabel(termin.type)}
</CategoryPill>
</div>
{termin.description ? (
<p className="whitespace-pre-wrap text-sm leading-6 text-[var(--ink-soft)]">
{termin.description}
</p>
) : (
<p className="text-sm italic text-[var(--ink-faint)]">
Keine Beschreibung hinterlegt.
</p>
)}
{termin.rsvpEvent ? (
<section className="rounded-lg border border-[var(--hairline)] p-4">
<h3 className="mb-3 font-medium">Eure Rückmeldung</h3>
<RsvpForm
key={JSON.stringify(termin.rsvpEvent)}
event={termin.rsvpEvent}
/>
</section>
) : null}
{termin.participantInfo && termin.showParticipantInfo && (
<div className="rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-sm text-[var(--ink)]">
<div className="mb-1 flex items-center gap-2 font-medium">
<AlertTriangle className="h-4 w-4 text-[var(--warn)] [stroke-width:1.7]" />
Wichtiger Hinweis für Teilnehmer
</div>
<p className="whitespace-pre-wrap">{termin.participantInfo}</p>
</div>
)}
{termin.mitbringselListEnabled && (
<div className="mt-3 border-t border-[var(--hairline-soft)] pt-3">
<MitbringselList
terminId={termin.id}
items={termin.mitbringselItems || []}
currentUserId={userId}
isAdmin={isAdmin}
/>
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
<DialogDescription>
Möchtest du den Termin &ldquo;{termin.title}&rdquo; wirklich
löschen? Alle zugehörigen Teilnahmen und Mitbringsel-Einträge
gehen verloren.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setDeleteOpen(false)}
disabled={isDeleting}
>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={isDeleting}
>
{isDeleting ? "Wird gelöscht..." : "Löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function DateTile({ date }: { date: Date }) {
return (
<div className="flex h-14 w-14 flex-col items-center justify-center rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)]">
<span className="text-[22px] font-medium leading-none tracking-[-0.02em] text-[var(--ink)] tabular-nums">
{format(date, "d", { locale: de })}
</span>
<span className="mt-1 text-[10px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.08em]">
{format(date, "EEE", { locale: de })}
</span>
</div>
);
}
function CategoryPill({
color,
children,
}: {
color: string;
children: React.ReactNode;
}) {
return (
<span
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-[var(--ink-soft)]"
style={{ color }}
>
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: color }}
/>
{children}
</span>
);
}
function groupByMonth(items: CalendarListItemDto[]) {
const groups = new Map<
string,
{ key: string; label: string; items: CalendarListItemDto[] }
>();
items.forEach((item) => {
const key = format(item.startDate, "yyyy-MM");
const label = format(item.startDate, "MMMM yyyy", { locale: de });
const group = groups.get(key) ?? { key, label, items: [] };
group.items.push(item);
groups.set(key, group);
});
return Array.from(groups.values());
}
function formatTerminTime(termin: TerminListItemDto) {
if (termin.allDay) return "ganztägig";
return `${format(termin.startDate, "HH:mm", { locale: de })} - ${format(
termin.endDate,
"HH:mm",
{ locale: de },
)}`;
}
function getTypeColor(type: TerminType): string {
switch (type) {
case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG:
return "var(--good)";
case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG:
return "var(--danger)";
case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN:
return "oklch(0.50 0.10 320)";
case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "oklch(0.50 0.10 280)";
default:
return "var(--ink-muted)";
}
}
function getTypeLabel(type: TerminType): string {
switch (type) {
case TerminType.KITA_FEST:
return "Kita-Fest";
case TerminType.MITMACH_TAG:
return "Mitmach-Tag";
case TerminType.SCHLIESSTAG:
return "Schließtag";
case TerminType.TEAMTAG:
return "Teamtag";
case TerminType.ELTERNABEND:
return "Elternabend";
case TerminType.MITGLIEDERVERSAMMLUNG:
return "Mitgliederversammlung";
case TerminType.ELTERNCAFE:
return "Elterncafe";
case TerminType.GEBURTSTAG_INTERN:
return "Geburtstag";
case TerminType.GEBURTSTAG_EXTERN:
return "Raumanfrage";
default:
return "Sonstiges";
}
}
export function NotdienstSwapDialog({
target,
options,
trigger,
}: {
target: NotdienstCalendarItemDto;
options: NotdienstSwapOptionDto[];
trigger: ReactNode;
}) {
const selectableOptions = options.filter((option) => option.id !== target.id);
const [open, setOpen] = useState(false);
const [offeredAssignmentId, setOfferedAssignmentId] = useState(
selectableOptions[0]?.id ?? "",
);
const [message, setMessage] = useState("");
const [isPending, startTransition] = useTransition();
const handleSubmit = () => {
if (!offeredAssignmentId) {
toast.error("Bitte wähle einen eigenen Notdienst als Tausch-Angebot.");
return;
}
startTransition(async () => {
const result = await createNotdienstSwapRequest({
targetAssignmentId: target.id,
offeredAssignmentId,
message,
});
if ("error" in result && result.error) {
toast.error(result.error);
return;
}
toast.success("Tauschanfrage wurde versendet.");
setOpen(false);
setMessage("");
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Notdienst tauschen</DialogTitle>
<DialogDescription>
Frage Familie {target.familyName}, ob ihr den Notdienst am{" "}
{format(target.startDate, "dd.MM.yyyy", { locale: de })} tauschen
könnt.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-[var(--ink)]">
Dein Tausch-Angebot
</label>
<select
className="w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
value={offeredAssignmentId}
disabled={isPending || selectableOptions.length === 0}
onChange={(event) => setOfferedAssignmentId(event.target.value)}
>
{selectableOptions.length === 0 ? (
<option value="">
Kein eigener zukünftiger Notdienst verfügbar
</option>
) : (
selectableOptions.map((option) => (
<option key={option.id} value={option.id}>
{format(option.date, "dd.MM.yyyy", { locale: de })} -{" "}
{option.childName}
</option>
))
)}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-[var(--ink)]">
Nachricht optional
</label>
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
maxLength={500}
rows={3}
className="w-full resize-none rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-sm text-[var(--ink)] ring-offset-[var(--surface)] placeholder:text-[var(--ink-muted)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
placeholder="Zum Beispiel: Wir sind an dem Tag verreist."
disabled={isPending}
/>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setOpen(false)}
disabled={isPending}
>
Abbrechen
</Button>
<Button
onClick={handleSubmit}
disabled={isPending || selectableOptions.length === 0}
>
Anfrage senden
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function NotdienstRow({
notdienst,
isAdmin,
notdienstSwapOptions,
}: {
notdienst: NotdienstCalendarItemDto;
isAdmin: boolean;
notdienstSwapOptions: NotdienstSwapOptionDto[];
}) {
const color = notdienst.isOwn ? "oklch(0.60 0.18 45)" : "oklch(0.75 0.10 50)";
const [menuOpen, setMenuOpen] = useState(false);
return (
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
<div
className="absolute inset-y-3 left-0 w-[3px] rounded-r-full"
style={{ backgroundColor: color }}
/>
<DateTile date={notdienst.startDate} />
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
{notdienst.title}
</h3>
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(notdienst.startDate, "dd.MM.yyyy", { locale: de })}{" "}
(ganztägig)
<span className="inline-flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--ink-muted)]" />
Kind: {notdienst.childName}
</span>
</p>
</div>
<div className="flex items-center gap-2 relative">
<CategoryPill color={color}>
{notdienst.isOwn ? "Mein Notdienst" : "Notdienst"}
</CategoryPill>
{isAdmin ? (
<div className="relative">
<Button
variant="ghost"
size="icon"
aria-label="Mehr Optionen"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
>
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
{menuOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<a
href={`/dashboard/notdienst/plan?year=${notdienst.planYear}&month=${notdienst.planMonth}`}
className="flex w-full items-center rounded-lg px-2.5 py-2 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]"
onClick={() => setMenuOpen(false)}
>
Notdienst im Planer bearbeiten
</a>
</div>
</>
)}
</div>
) : null}
{notdienst.canRequestSwap ? (
<NotdienstSwapDialog
target={notdienst}
options={notdienstSwapOptions}
trigger={
<Button size="sm" variant="outline" className="h-8 px-2.5 text-xs">
Tausch anfragen
</Button>
}
/>
) : !isAdmin ? <div className="w-9 h-9" /> : null}
</div>
</div>
);
}