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

555 lines
19 KiB
TypeScript

"use client";
import { useState, useTransition } from "react";
import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import {
AlertTriangle,
Calendar,
Clock,
MapPin,
MoreHorizontal,
WashingMachine,
Trash2,
ShieldAlert,
} from "lucide-react";
import { toast } from "sonner";
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 { 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;
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;
title: string;
familyName: string;
childName: string;
isOwn: boolean;
startDate: Date;
endDate: Date;
};
export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto | NotdienstCalendarItemDto;
export function TerminList({
termine,
userId,
isAdmin,
}: {
termine: CalendarListItemDto[];
userId: string;
isAdmin: boolean;
}) {
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} />
) : (
<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.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";
}
}
function NotdienstRow({ notdienst, isAdmin }: { notdienst: NotdienstCalendarItemDto; isAdmin: boolean }) {
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"
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>
) : (
<div className="w-9 h-9" />
)}
</div>
</div>
);
}