Implement missing UI button features, filters, search, and view toggles across all modules

This commit is contained in:
t.indorf
2026-05-21 10:44:23 +02:00
parent e7eb43462c
commit 84aae1d72e
18 changed files with 3268 additions and 958 deletions
@@ -1,7 +1,7 @@
"use client";
import type { ComponentProps, FormEvent } from "react";
import { useMemo, useState, useTransition } from "react";
import { useEffect, useMemo, useState, useTransition } from "react";
import { TerminType } from "@prisma/client";
import { Plus } from "lucide-react";
import { toast } from "sonner";
@@ -18,7 +18,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { createTerminAdmin } from "../actions";
import { createTerminAdmin, updateTerminAdmin } from "../actions";
import { Textarea } from "@/components/ui/textarea";
type AdminTerminFormState = {
@@ -43,12 +43,22 @@ type AdminTerminModalProps = {
triggerLabel?: string;
triggerVariant?: ComponentProps<typeof Button>["variant"];
triggerClassName?: string;
existingTermin?: {
id: string;
title: string;
description: string | null;
type: TerminType;
startDate: Date;
endDate: Date;
allDay: boolean;
};
};
export function AdminTerminModal({
triggerLabel = "Termin direkt anlegen",
triggerVariant,
triggerClassName = "gap-2",
existingTermin,
}: AdminTerminModalProps) {
const [open, setOpen] = useState(false);
const [formState, setFormState] = useState<AdminTerminFormState>(initialFormState);
@@ -56,6 +66,24 @@ export function AdminTerminModal({
const minDateTime = useMemo(() => getLocalDateTimeInputValue(new Date()), []);
const minEndDateTime = formState.startDate || minDateTime;
// Initialize and reset form fields when modal opens
useEffect(() => {
if (open) {
if (existingTermin) {
setFormState({
title: existingTermin.title,
description: existingTermin.description || "",
type: existingTermin.type,
startDate: getLocalDateTimeInputValue(existingTermin.startDate),
endDate: getLocalDateTimeInputValue(existingTermin.endDate),
allDay: existingTermin.allDay,
});
} else {
setFormState(initialFormState);
}
}
}, [open, existingTermin]);
const updateField = <TKey extends keyof AdminTerminFormState>(
key: TKey,
value: AdminTerminFormState[TKey],
@@ -76,7 +104,8 @@ export function AdminTerminModal({
toast.error("Bitte Start und Ende ausfüllen.");
return;
}
if (formState.startDate < minDateTime) {
// Only enforce future start date when creating a new event
if (!existingTermin && formState.startDate < minDateTime) {
toast.error("Der Startzeitpunkt darf nicht in der Vergangenheit liegen.");
return;
}
@@ -95,12 +124,24 @@ export function AdminTerminModal({
};
startTransition(async () => {
const res = await createTerminAdmin(data);
let res;
if (existingTermin) {
res = await updateTerminAdmin(existingTermin.id, data);
} else {
res = await createTerminAdmin(data);
}
if (res.error) {
toast.error(res.error);
} else {
toast.success("Termin wurde direkt angelegt!");
setFormState(initialFormState);
toast.success(
existingTermin
? "Termin wurde erfolgreich aktualisiert!"
: "Termin wurde direkt angelegt!"
);
if (!existingTermin) {
setFormState(initialFormState);
}
setOpen(false);
}
});
@@ -110,16 +151,20 @@ export function AdminTerminModal({
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant={triggerVariant} className={triggerClassName}>
<Plus className="h-4 w-4" />
{!existingTermin && <Plus className="h-4 w-4" />}
{triggerLabel}
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Termin anlegen (Admin)</DialogTitle>
<DialogTitle>
{existingTermin ? "Termin bearbeiten (Admin)" : "Termin anlegen (Admin)"}
</DialogTitle>
<DialogDescription>
Dieser Termin wird sofort freigegeben und ist für alle sichtbar.
{existingTermin
? "Nimm Änderungen an diesem Termin vor. Die Änderungen werden sofort wirksam."
: "Dieser Termin wird sofort freigegeben und ist für alle sichtbar."}
</DialogDescription>
</DialogHeader>
@@ -177,7 +222,7 @@ export function AdminTerminModal({
id="startDate"
name="startDate"
type="datetime-local"
min={minDateTime}
min={existingTermin ? undefined : minDateTime}
value={formState.startDate}
onChange={(event) => updateField("startDate", event.target.value)}
required
@@ -189,7 +234,7 @@ export function AdminTerminModal({
id="endDate"
name="endDate"
type="datetime-local"
min={minEndDateTime}
min={formState.startDate || (existingTermin ? undefined : minDateTime)}
value={formState.endDate}
onChange={(event) => updateField("endDate", event.target.value)}
required
@@ -217,7 +262,7 @@ export function AdminTerminModal({
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Speichern..." : "Termin erstellen"}
{isPending ? "Speichern..." : existingTermin ? "Änderungen speichern" : "Termin erstellen"}
</Button>
</DialogFooter>
</form>
@@ -0,0 +1,774 @@
"use client";
import { useState, useMemo, useTransition } from "react";
import {
TerminType,
TerminStatus,
EventParticipationStatus,
DutyAssignmentStatus,
} from "@prisma/client";
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isSameMonth,
isSameDay,
addMonths,
subMonths,
} from "date-fns";
import { de } from "date-fns/locale";
import {
Search,
ListFilter,
ChevronLeft,
ChevronRight,
Calendar as CalendarIcon,
Clock,
MapPin,
AlertTriangle,
WashingMachine,
MoreHorizontal,
Trash2,
Edit,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import { CalendarListItemDto } from "./termin-list";
import { TerminList } from "./termin-list";
import { MitbringselList } from "./mitbringsel-list";
import { AdminTerminModal } from "./admin-termin-modal";
import { deleteTerminAdmin } from "../actions";
const categories = [
{ label: "Schließtag", type: TerminType.SCHLIESSTAG, color: "var(--danger)" },
{ label: "Teamtag", type: TerminType.TEAMTAG, color: "var(--danger)" },
{ label: "Elternabend", type: TerminType.ELTERNABEND, color: "oklch(0.50 0.10 280)" },
{ label: "Fest", type: TerminType.KITA_FEST, color: "var(--good)" },
{ label: "Geburtstag", type: TerminType.GEBURTSTAG_INTERN, color: "oklch(0.50 0.10 320)" },
{ label: "Elterndienst", type: "elterndienst", color: "oklch(0.60 0.15 160)" },
{ label: "Sonstiges", type: TerminType.SONSTIGES, color: "var(--ink-muted)" },
];
export function CalendarOverviewClient({
termine,
userId,
isAdmin,
}: {
termine: CalendarListItemDto[];
userId: string;
isAdmin: boolean;
}) {
const [view, setView] = useState<"liste" | "monat" | "agenda">("liste");
const [searchQuery, setSearchQuery] = useState("");
const [activeTypes, setActiveTypes] = useState<Set<string>>(new Set());
// Month navigation state
const [currentMonthDate, setCurrentMonthDate] = useState(new Date());
// Dialog state for day clicking in Month view
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
// Delete event states
const [deleteEventId, setDeleteEventId] = useState<string | null>(null);
const [deleteEventTitle, setDeleteEventTitle] = useState("");
const [isDeleting, startDeleteTransition] = useTransition();
const toggleType = (type: string) => {
setActiveTypes((prev) => {
const next = new Set(prev);
if (next.has(type)) {
next.delete(type);
} else {
next.add(type);
}
return next;
});
};
// Filter logic
const filteredEvents = useMemo(() => {
return termine.filter((item) => {
// 1. Search Query filter
const query = searchQuery.trim().toLowerCase();
if (query) {
const titleMatch = item.title.toLowerCase().includes(query);
const descMatch =
item.kind === "termin" && item.description
? item.description.toLowerCase().includes(query)
: false;
const familyMatch =
item.kind === "duty" && item.familyName
? item.familyName.toLowerCase().includes(query)
: false;
if (!titleMatch && !descMatch && !familyMatch) return false;
}
// 2. Category filter
if (activeTypes.size > 0) {
const itemType = item.kind === "duty" ? "elterndienst" : item.type;
if (!activeTypes.has(itemType)) return false;
}
return true;
});
}, [termine, searchQuery, activeTypes]);
// Check if an event falls on a specific day
const isEventOnDay = (item: CalendarListItemDto, day: Date) => {
const eventStart = new Date(item.startDate);
const eventEnd = new Date(item.endDate);
const dayTime = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime();
const startTime = new Date(eventStart.getFullYear(), eventStart.getMonth(), eventStart.getDate()).getTime();
const endTime = new Date(eventEnd.getFullYear(), eventEnd.getMonth(), eventEnd.getDate()).getTime();
return dayTime >= startTime && dayTime <= endTime;
};
// Get events for selected day (using all filtered events or all events? We use filtered to keep visual filters consistent!)
const selectedDayEvents = useMemo(() => {
if (!selectedDay) return [];
return filteredEvents.filter((item) => isEventOnDay(item, selectedDay));
}, [selectedDay, filteredEvents]);
// Handle event delete
const handleDeleteConfirm = () => {
if (!deleteEventId) return;
startDeleteTransition(async () => {
const res = await deleteTerminAdmin(deleteEventId);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Termin wurde erfolgreich gelöscht.");
setDeleteEventId(null);
// If deleting from the day dialog, update selectedDay if it becomes empty or keep it open
}
});
};
return (
<div className="space-y-5">
{/* Search and Filters Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
{(["liste", "monat", "agenda"] as const).map((v) => (
<button
key={v}
type="button"
onClick={() => setView(v)}
className={[
"h-9 px-4 text-[13px] font-semibold transition",
v !== "liste" ? "border-l border-[var(--hairline-soft)]" : "",
view === v
? "bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
].join(" ")}
>
{v.charAt(0).toUpperCase() + v.slice(1)}
</button>
))}
</div>
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">
{/* Category chips */}
<div className="flex flex-wrap gap-1.5">
<span className="inline-flex h-9 items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-medium text-[var(--ink-soft)]">
<ListFilter className="h-3.5 w-3.5 [stroke-width:1.7]" />
Filter
</span>
{categories.map((cat) => {
const isActive = activeTypes.has(cat.type);
return (
<button
key={cat.label}
type="button"
onClick={() => toggleType(cat.type)}
className={[
"inline-flex h-9 items-center gap-1.5 rounded-full border px-3 text-[13px] font-medium transition",
isActive
? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
].join(" ")}
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: cat.color }}
/>
{cat.label}
</button>
);
})}
</div>
<div className="relative min-w-56">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input
placeholder="Termine suchen"
className="h-9 pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
</div>
{/* Main View Contents */}
{view === "liste" && (
<TerminList termine={filteredEvents} userId={userId} isAdmin={isAdmin} />
)}
{view === "monat" && (
<MonthView
currentMonthDate={currentMonthDate}
setCurrentMonthDate={setCurrentMonthDate}
events={filteredEvents}
isEventOnDay={isEventOnDay}
onDayClick={(day) => setSelectedDay(day)}
/>
)}
{view === "agenda" && (
<AgendaView
events={filteredEvents}
isAdmin={isAdmin}
userId={userId}
onDeleteTermin={(id, title) => {
setDeleteEventId(id);
setDeleteEventTitle(title);
}}
/>
)}
{/* Day details Dialog for Month View */}
<Dialog open={!!selectedDay} onOpenChange={(open) => !open && setSelectedDay(null)}>
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-xl">
{selectedDay && format(selectedDay, "EEEE, d. MMMM yyyy", { locale: de })}
</DialogTitle>
<DialogDescription>
Übersicht aller Zuteilungen, Feste und Einträge an diesem Tag.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
{selectedDayEvents.length === 0 ? (
<p className="text-sm italic text-[var(--ink-soft)] text-center py-8">
Keine Termine oder Dienste an diesem Tag.
</p>
) : (
selectedDayEvents.map((item) => (
<div
key={item.id}
className="relative rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm"
>
{item.kind === "duty" ? (
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--good-soft)] text-[var(--good)]">
<WashingMachine className="h-5 w-5 [stroke-width:1.7]" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h4 className="font-semibold text-[15px] text-[var(--ink)] truncate">
{item.title}
</h4>
<Badge variant="secondary" className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)]">
Elterndienst
</Badge>
</div>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Familie: {item.familyName}
</p>
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{format(new Date(item.startDate), "dd.MM.")} -{" "}
{format(new Date(item.endDate), "dd.MM.yyyy")}
</p>
{isAdmin && (
<div className="mt-3 flex gap-2">
<a href="/dashboard/admin/dienste">
<Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
Im Planer bearbeiten
</Button>
</a>
</div>
)}
</div>
</div>
) : (
<div className="flex items-start gap-3">
<div
className="absolute inset-y-3 left-0 w-[4px] rounded-r-full"
style={{ backgroundColor: getCategoryColor(item.type) }}
/>
<div className="pl-2 flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h4 className="font-semibold text-[15px] text-[var(--ink)] truncate">
{item.title}
</h4>
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold border border-[var(--hairline)]"
style={{
color: getCategoryColor(item.type),
borderColor: getCategoryColor(item.type) + "44",
backgroundColor: getCategoryColor(item.type) + "11",
}}
>
{getTypeLabel(item.type)}
</span>
</div>
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{item.allDay
? "Ganztägig"
: `${format(new Date(item.startDate), "HH:mm")} - ${format(
new Date(item.endDate),
"HH:mm",
)}`}
</p>
{item.description && (
<p className="mt-2 text-sm text-[var(--ink-soft)] whitespace-pre-wrap leading-relaxed">
{item.description}
</p>
)}
{item.participantInfo && item.showParticipantInfo && (
<div className="mt-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-3 text-xs text-[var(--ink)]">
<div className="mb-0.5 flex items-center gap-1.5 font-medium">
<AlertTriangle className="h-3.5 w-3.5 text-[var(--warn)]" />
Hinweis für Teilnehmer
</div>
<p className="whitespace-pre-wrap">{item.participantInfo}</p>
</div>
)}
{item.mitbringselListEnabled && (
<div className="mt-3">
<MitbringselList
terminId={item.id}
items={item.mitbringselItems || []}
currentUserId={userId}
isAdmin={isAdmin}
/>
</div>
)}
{isAdmin && (
<div className="mt-3 flex gap-2 border-t border-[var(--hairline-soft)] pt-3">
<AdminTerminModal
existingTermin={item}
triggerLabel="Bearbeiten"
triggerVariant="outline"
triggerClassName="h-7 text-xs gap-1 px-2.5"
/>
<Button
size="sm"
variant="outline"
className="h-7 text-xs text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)] gap-1 px-2.5 border-[var(--hairline)]"
onClick={() => {
setDeleteEventId(item.id);
setDeleteEventTitle(item.title);
}}
>
<Trash2 className="h-3 w-3" />
Löschen
</Button>
</div>
)}
</div>
</div>
)}
</div>
))
)}
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deleteEventId} onOpenChange={(open) => !open && setDeleteEventId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
<DialogDescription>
Möchtest du den Termin &ldquo;{deleteEventTitle}&rdquo; wirklich löschen? Alle
zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setDeleteEventId(null)}
disabled={isDeleting}
>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={isDeleting}
>
{isDeleting ? "Wird gelöscht..." : "Löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// =====================================================================
// Month Grid Component
// =====================================================================
function MonthView({
currentMonthDate,
setCurrentMonthDate,
events,
isEventOnDay,
onDayClick,
}: {
currentMonthDate: Date;
setCurrentMonthDate: (date: Date) => void;
events: CalendarListItemDto[];
isEventOnDay: (item: CalendarListItemDto, day: Date) => boolean;
onDayClick: (day: Date) => void;
}) {
const monthStart = startOfMonth(currentMonthDate);
const monthEnd = endOfMonth(monthStart);
const startDate = startOfWeek(monthStart, { weekStartsOn: 1 });
const endDate = endOfWeek(monthEnd, { weekStartsOn: 1 });
const days = useMemo(() => {
return eachDayOfInterval({ start: startDate, end: endDate });
}, [startDate, endDate]);
const weekdays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
return (
<Card className="overflow-hidden border border-[var(--hairline)] shadow-sm">
<div className="flex items-center justify-between border-b border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-3">
<h3 className="text-[16px] font-semibold text-[var(--ink)]">
{format(currentMonthDate, "MMMM yyyy", { locale: de })}
</h3>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setCurrentMonthDate(subMonths(currentMonthDate, 1))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => setCurrentMonthDate(new Date())}
className="h-8 text-xs font-semibold px-2.5"
>
Heute
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setCurrentMonthDate(addMonths(currentMonthDate, 1))}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-7 border-b border-[var(--hairline-soft)] bg-[var(--surface-3)]/20 py-2 text-center text-xs font-semibold text-[var(--ink-soft)]">
{weekdays.map((day) => (
<div key={day}>{day}</div>
))}
</div>
<div className="grid grid-cols-7 bg-[var(--hairline-soft)] gap-[1px]">
{days.map((day, idx) => {
const isCurrentMonth = isSameMonth(day, currentMonthDate);
const isToday = isSameDay(day, new Date());
const dayEvents = events.filter((item) => isEventOnDay(item, day));
return (
<div
key={day.toString() + idx}
onClick={() => onDayClick(day)}
className={[
"min-h-[105px] bg-[var(--surface)] p-2 transition-colors hover:bg-[var(--surface-2)] cursor-pointer flex flex-col justify-between",
!isCurrentMonth ? "bg-[var(--surface-3)]/30 text-[var(--ink-muted)] opacity-60" : "text-[var(--ink)]",
].join(" ")}
>
{/* Day number cell top */}
<div className="flex items-center justify-between">
<span
className={[
"text-xs font-semibold flex h-6 w-6 items-center justify-center rounded-full",
isToday
? "bg-[var(--accent-deep)] text-[oklch(0.98_0.005_45)] shadow-sm"
: "",
].join(" ")}
>
{format(day, "d")}
</span>
{dayEvents.length > 0 && (
<span className="text-[10px] font-semibold text-[var(--ink-muted)] tabular-nums">
{dayEvents.length} {dayEvents.length === 1 ? "Eintrag" : "Einträge"}
</span>
)}
</div>
{/* Event indicators/snippets */}
<div className="mt-2 space-y-1 flex-1 flex flex-col justify-end">
{dayEvents.slice(0, 3).map((item) => {
const color =
item.kind === "duty"
? "oklch(0.60 0.15 160)"
: getCategoryColor(item.type);
return (
<div
key={item.id}
className="group relative flex items-center gap-1 rounded bg-[var(--surface-2)] px-1.5 py-0.5 text-[10px] font-medium border border-[var(--hairline-soft)]"
>
<span
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="truncate text-[var(--ink-soft)] max-w-full">
{item.title}
</span>
</div>
);
})}
{dayEvents.length > 3 && (
<div className="text-[9px] font-semibold text-[var(--accent-deep)] pl-1.5">
+ {dayEvents.length - 3} weitere
</div>
)}
</div>
</div>
);
})}
</div>
</Card>
);
}
// =====================================================================
// Agenda Timeline View Component
// =====================================================================
function AgendaView({
events,
isAdmin,
userId,
onDeleteTermin,
}: {
events: CalendarListItemDto[];
isAdmin: boolean;
userId: string;
onDeleteTermin: (id: string, title: string) => void;
}) {
const grouped = useMemo(() => {
const groups = new Map<string, { date: Date; items: CalendarListItemDto[] }>();
events.forEach((item) => {
const key = format(new Date(item.startDate), "yyyy-MM-dd");
const group = groups.get(key) ?? { date: new Date(item.startDate), items: [] };
group.items.push(item);
groups.set(key, group);
});
return Array.from(groups.values()).sort((a, b) => a.date.getTime() - b.date.getTime());
}, [events]);
if (events.length === 0) {
return (
<Card>
<CardContent className="flex min-h-[220px] flex-col items-center justify-center text-center p-6">
<CalendarIcon className="h-8 w-8 text-[var(--ink-muted)] mb-2" />
<h3 className="text-[16px] font-semibold text-[var(--ink)]">
Keine Termine oder Dienste gefunden
</h3>
<p className="text-sm text-[var(--ink-soft)] mt-1 max-w-xs">
Passe deine Filter an oder suche nach einem anderen Begriff.
</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6 pl-4 border-l-2 border-[var(--hairline)]">
{grouped.map((group) => (
<div key={group.date.toString()} className="relative grid grid-cols-[110px_1fr] gap-4 items-start">
{/* Timeline marker */}
<div className="absolute -left-[21px] top-1.5 h-3.5 w-3.5 rounded-full border-2 border-[var(--accent-deep)] bg-[var(--surface)]" />
{/* Date Label on left */}
<div className="pt-0.5">
<div className="text-[14px] font-bold text-[var(--ink)] leading-none">
{format(group.date, "d. MMMM", { locale: de })}
</div>
<div className="text-[11px] font-semibold uppercase tracking-wider text-[var(--ink-muted)] mt-1">
{format(group.date, "EEEE", { locale: de })}
</div>
</div>
{/* Items stack on right */}
<div className="space-y-3">
{group.items.map((item) => {
const isTermin = item.kind === "termin";
const color = isTermin ? getCategoryColor(item.type) : "oklch(0.60 0.15 160)";
return (
<div
key={item.id}
className="group relative flex items-start justify-between gap-4 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm transition-shadow hover:shadow-md"
>
<div className="flex items-start gap-3 min-w-0">
<div
className="absolute inset-y-3 left-0 w-[4px] rounded-r-full"
style={{ backgroundColor: color }}
/>
<div className="pl-1 min-w-0">
<div className="flex items-center flex-wrap gap-2">
<h4 className="font-bold text-[15px] text-[var(--ink)] truncate">
{item.title}
</h4>
{isTermin ? (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold border"
style={{
color,
borderColor: color + "44",
backgroundColor: color + "11",
}}
>
{getTypeLabel(item.type)}
</span>
) : (
<Badge variant="secondary" className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)] text-[10px]">
Elterndienst
</Badge>
)}
{isTermin && item.status === TerminStatus.PENDING && (
<Badge variant="warning" className="text-[10px]">Ausstehend</Badge>
)}
</div>
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
{isTermin
? item.allDay
? "Ganztägig"
: `${format(new Date(item.startDate), "HH:mm")} - ${format(
new Date(item.endDate),
"HH:mm",
)}`
: `${format(new Date(item.startDate), "dd.MM.")} - ${format(
new Date(item.endDate),
"dd.MM.yyyy",
)}`}
{isTermin && <MapPin className="ml-1 h-3.5 w-3.5" />}
{isTermin ? "Kita" : `Familie: ${item.familyName}`}
</p>
{isTermin && item.description && (
<p className="mt-2 text-sm text-[var(--ink-soft)] line-clamp-2 leading-relaxed">
{item.description}
</p>
)}
</div>
</div>
{/* Actions for admins */}
{isAdmin && isTermin && (
<div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<AdminTerminModal
existingTermin={item}
triggerLabel=""
triggerVariant="ghost"
triggerClassName="h-8 w-8 p-0"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-[var(--ink-muted)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => onDeleteTermin(item.id, item.title)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
})}
</div>
</div>
))}
</div>
);
}
// =====================================================================
// Helper functions for categories/labels
// =====================================================================
function getCategoryColor(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";
}
}
@@ -1,5 +1,6 @@
"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";
@@ -10,7 +11,9 @@ import {
MapPin,
MoreHorizontal,
WashingMachine,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -22,9 +25,12 @@ import {
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";
@@ -62,6 +68,7 @@ export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto;
export function TerminList({
termine,
userId,
isAdmin,
}: {
termine: CalendarListItemDto[];
@@ -115,9 +122,9 @@ export function TerminList({
<div className="divide-y divide-[var(--hairline-soft)]">
{group.items.map((item) =>
item.kind === "duty" ? (
<DutyRow key={`duty-${item.id}`} duty={item} />
<DutyRow key={`duty-${item.id}`} duty={item} isAdmin={isAdmin} />
) : (
<TerminRow key={`termin-${item.id}`} termin={item} />
<TerminRow key={`termin-${item.id}`} termin={item} userId={userId} isAdmin={isAdmin} />
),
)}
</div>
@@ -128,8 +135,9 @@ export function TerminList({
);
}
function DutyRow({ duty }: { duty: DutyCalendarItemDto }) {
function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolean }) {
const color = "var(--good)";
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)]">
@@ -147,92 +155,224 @@ function DutyRow({ duty }: { duty: DutyCalendarItemDto }) {
{duty.familyName}
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 relative">
<CategoryPill color={color}>Elterndienst</CategoryPill>
<Button variant="ghost" size="icon" aria-label="Mehr Optionen">
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
{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 }: { termin: TerminListItemDto }) {
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>
<>
<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>
<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">
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
Details
</Button>
</DialogTrigger>
<Button variant="ghost" size="icon" aria-label="Mehr Optionen">
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</div>
</div>
<DialogContent>
<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>
<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">
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
{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 className="grid gap-4">
<div className="flex">
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
</div>
)}
</div>
</DialogContent>
</Dialog>
{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>
</>
);
}