555 lines
19 KiB
TypeScript
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 “{termin.title}” 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>
|
|
);
|
|
}
|