1086 lines
42 KiB
TypeScript
1086 lines
42 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo, useTransition } from "react";
|
|
import {
|
|
TerminType,
|
|
TerminStatus,
|
|
} 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,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import { RsvpDialog, RsvpForm } from "@/components/rsvp-form";
|
|
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,
|
|
NotdienstSwapDialog,
|
|
NotdienstSwapOptionDto,
|
|
TerminList,
|
|
} from "./termin-list";
|
|
import { MitbringselList } from "./mitbringsel-list";
|
|
import { AdminTerminModal } from "./admin-termin-modal";
|
|
import {
|
|
acceptNotdienstSwapRequest,
|
|
cancelNotdienstSwapRequest,
|
|
declineNotdienstSwapRequest,
|
|
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: "Mein Elterndienst", type: "mein-elterndienst", color: "oklch(0.60 0.16 160)" },
|
|
{ label: "Elterndienst", type: "elterndienst", color: "oklch(0.75 0.08 160)" },
|
|
{ label: "Mein Notdienst", type: "mein-notdienst", color: "oklch(0.60 0.18 45)" },
|
|
{ label: "Notdienst", type: "notdienst", color: "oklch(0.75 0.10 50)" },
|
|
{ label: "Sonstiges", type: TerminType.SONSTIGES, color: "var(--ink-muted)" },
|
|
];
|
|
|
|
export type NotdienstSwapRequestDto = {
|
|
id: string;
|
|
direction: "incoming" | "outgoing";
|
|
targetDate: Date;
|
|
targetFamilyName: string;
|
|
targetChildName: string;
|
|
offeredDate: Date;
|
|
offeredFamilyName: string;
|
|
offeredChildName: string;
|
|
message: string | null;
|
|
};
|
|
|
|
export function CalendarOverviewClient({
|
|
termine,
|
|
userId,
|
|
isAdmin,
|
|
notdienstSwapOptions = [],
|
|
pendingSwapRequests = [],
|
|
}: {
|
|
termine: CalendarListItemDto[];
|
|
userId: string;
|
|
isAdmin: boolean;
|
|
notdienstSwapOptions?: NotdienstSwapOptionDto[];
|
|
pendingSwapRequests?: NotdienstSwapRequestDto[];
|
|
}) {
|
|
const [view, setView] = useState<"liste" | "monat" | "agenda">("monat");
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [activeTypes, setActiveTypes] = useState<Set<string>>(new Set());
|
|
const [filterScope, setFilterScope] = useState<"own" | "all">(isAdmin ? "all" : "own");
|
|
|
|
// 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) => {
|
|
// 0. Filter by scope (own vs all)
|
|
if (filterScope === "own") {
|
|
if (item.kind === "duty" && !item.isOwn) return false;
|
|
if (item.kind === "notdienst" && !item.isOwn) return false;
|
|
}
|
|
|
|
// 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.kind === "notdienst") && item.familyName
|
|
? item.familyName.toLowerCase().includes(query)
|
|
: false;
|
|
const childMatch =
|
|
item.kind === "notdienst" && item.childName
|
|
? item.childName.toLowerCase().includes(query)
|
|
: false;
|
|
if (!titleMatch && !descMatch && !familyMatch && !childMatch) return false;
|
|
}
|
|
|
|
// 2. Category filter
|
|
if (activeTypes.size > 0) {
|
|
const itemType =
|
|
item.kind === "duty"
|
|
? item.isOwn
|
|
? "mein-elterndienst"
|
|
: "elterndienst"
|
|
: item.kind === "notdienst"
|
|
? item.isOwn
|
|
? "mein-notdienst"
|
|
: "notdienst"
|
|
: item.type;
|
|
if (!activeTypes.has(itemType)) return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}, [termine, searchQuery, activeTypes, filterScope]);
|
|
|
|
// 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">
|
|
{!isAdmin && pendingSwapRequests.length > 0 && (
|
|
<NotdienstSwapRequestsPanel requests={pendingSwapRequests} />
|
|
)}
|
|
|
|
{/* Search and Filters Header */}
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center flex-wrap 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>
|
|
|
|
{!isAdmin && (
|
|
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)] p-0.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => setFilterScope("own")}
|
|
className={[
|
|
"h-8 px-4 rounded-full text-[12px] font-semibold transition-all duration-200",
|
|
filterScope === "own"
|
|
? "bg-[var(--accent-deep)] text-[var(--surface)] shadow-sm"
|
|
: "text-[var(--ink-soft)] hover:text-[var(--ink)] hover:bg-[var(--surface-2)]",
|
|
].join(" ")}
|
|
>
|
|
Meine Dienste
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFilterScope("all")}
|
|
className={[
|
|
"h-8 px-4 rounded-full text-[12px] font-semibold transition-all duration-200",
|
|
filterScope === "all"
|
|
? "bg-[var(--accent-deep)] text-[var(--surface)] shadow-sm"
|
|
: "text-[var(--ink-soft)] hover:text-[var(--ink)] hover:bg-[var(--surface-2)]",
|
|
].join(" ")}
|
|
>
|
|
Alle Dienste
|
|
</button>
|
|
</div>
|
|
)}
|
|
</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}
|
|
notdienstSwapOptions={notdienstSwapOptions}
|
|
/>
|
|
)}
|
|
|
|
{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="absolute inset-y-3 left-0 w-[4px] rounded-r-full"
|
|
style={{
|
|
backgroundColor: item.isOwn
|
|
? "oklch(0.60 0.16 160)"
|
|
: "oklch(0.75 0.08 160)",
|
|
}}
|
|
/>
|
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--surface-2)] text-[var(--ink-soft)] ml-2">
|
|
<WashingMachine className="h-5 w-5 [stroke-width:1.7]" style={{ color: item.isOwn ? "oklch(0.60 0.16 160)" : "oklch(0.75 0.08 160)" }} />
|
|
</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"
|
|
style={{
|
|
color: item.isOwn
|
|
? "oklch(0.60 0.16 160)"
|
|
: "oklch(0.75 0.08 160)",
|
|
borderColor:
|
|
(item.isOwn
|
|
? "oklch(0.60 0.16 160)"
|
|
: "oklch(0.75 0.08 160)") + "44",
|
|
backgroundColor:
|
|
(item.isOwn
|
|
? "oklch(0.60 0.16 160)"
|
|
: "oklch(0.75 0.08 160)") + "11",
|
|
}}
|
|
className="border text-[10px]"
|
|
>
|
|
{item.isOwn ? "Mein Elterndienst" : "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>
|
|
) : item.kind === "notdienst" ? (
|
|
<div className="flex items-start gap-3">
|
|
<div
|
|
className="absolute inset-y-3 left-0 w-[4px] rounded-r-full"
|
|
style={{
|
|
backgroundColor: item.isOwn
|
|
? "oklch(0.60 0.18 45)"
|
|
: "oklch(0.75 0.10 50)",
|
|
}}
|
|
/>
|
|
<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>
|
|
<Badge
|
|
variant="secondary"
|
|
style={{
|
|
color: item.isOwn
|
|
? "oklch(0.60 0.18 45)"
|
|
: "oklch(0.75 0.10 50)",
|
|
borderColor:
|
|
(item.isOwn
|
|
? "oklch(0.60 0.18 45)"
|
|
: "oklch(0.75 0.10 50)") + "44",
|
|
backgroundColor:
|
|
(item.isOwn
|
|
? "oklch(0.60 0.18 45)"
|
|
: "oklch(0.75 0.10 50)") + "11",
|
|
}}
|
|
className="border text-[10px]"
|
|
>
|
|
{item.isOwn ? "Mein Notdienst" : "Notdienst"}
|
|
</Badge>
|
|
</div>
|
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
|
Kind: {item.childName} (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.yyyy")}
|
|
</p>
|
|
{isAdmin && (
|
|
<div className="mt-3 flex gap-2">
|
|
<a href={`/dashboard/notdienst/plan?year=${item.planYear}&month=${item.planMonth}`}>
|
|
<Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
|
|
Im Planer bearbeiten
|
|
</Button>
|
|
</a>
|
|
</div>
|
|
)}
|
|
{!isAdmin && item.canRequestSwap && (
|
|
<div className="mt-3 flex gap-2">
|
|
<NotdienstSwapDialog
|
|
target={item}
|
|
options={notdienstSwapOptions}
|
|
trigger={
|
|
<Button size="sm" variant="outline" className="h-7 px-2.5 text-xs">
|
|
Tausch anfragen
|
|
</Button>
|
|
}
|
|
/>
|
|
</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.rsvpEvent ? <section className="mt-4 border-t border-[var(--hairline)] pt-4">
|
|
<h5 className="mb-3 font-medium">Eure Rückmeldung</h5>
|
|
<RsvpForm key={JSON.stringify(item.rsvpEvent)} event={item.rsvpEvent} />
|
|
</section> : null}
|
|
|
|
{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 “{deleteEventTitle}” 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>
|
|
);
|
|
}
|
|
|
|
function NotdienstSwapRequestsPanel({
|
|
requests,
|
|
}: {
|
|
requests: NotdienstSwapRequestDto[];
|
|
}) {
|
|
return (
|
|
<Card className="p-0">
|
|
<div className="border-b border-[var(--hairline-soft)] px-5 py-4">
|
|
<h2 className="text-base font-semibold tracking-[-0.01em] text-[var(--ink)]">
|
|
Offene Notdienst-Tausche
|
|
</h2>
|
|
</div>
|
|
<div className="divide-y divide-[var(--hairline-soft)]">
|
|
{requests.map((request) => (
|
|
<NotdienstSwapRequestRow key={request.id} request={request} />
|
|
))}
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function NotdienstSwapRequestRow({
|
|
request,
|
|
}: {
|
|
request: NotdienstSwapRequestDto;
|
|
}) {
|
|
const [isPending, startTransition] = useTransition();
|
|
|
|
const runAction = (
|
|
action: (requestId: string) => Promise<{ success?: boolean; error?: string }>,
|
|
successMessage: string,
|
|
) => {
|
|
startTransition(async () => {
|
|
const result = await action(request.id);
|
|
if (result.error) {
|
|
toast.error(result.error);
|
|
return;
|
|
}
|
|
toast.success(successMessage);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-wrap items-center justify-between gap-4 px-5 py-4">
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-semibold text-[var(--ink)]">
|
|
{request.direction === "incoming" ? "Anfrage an dich" : "Deine Anfrage"}
|
|
</p>
|
|
<p className="mt-1 text-sm leading-5 text-[var(--ink-soft)]">
|
|
{format(request.targetDate, "dd.MM.yyyy", { locale: de })}{" "}
|
|
({request.targetFamilyName}, {request.targetChildName}) gegen{" "}
|
|
{format(request.offeredDate, "dd.MM.yyyy", { locale: de })}{" "}
|
|
({request.offeredFamilyName}, {request.offeredChildName})
|
|
</p>
|
|
{request.message && (
|
|
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
|
|
{request.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex shrink-0 gap-2">
|
|
{request.direction === "incoming" ? (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
onClick={() =>
|
|
runAction(acceptNotdienstSwapRequest, "Tausch wurde angenommen.")
|
|
}
|
|
disabled={isPending}
|
|
>
|
|
Annehmen
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() =>
|
|
runAction(declineNotdienstSwapRequest, "Tausch wurde abgelehnt.")
|
|
}
|
|
disabled={isPending}
|
|
>
|
|
Ablehnen
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() =>
|
|
runAction(cancelNotdienstSwapRequest, "Tauschanfrage wurde zurückgezogen.")
|
|
}
|
|
disabled={isPending}
|
|
>
|
|
Zurückziehen
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</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>
|
|
</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"
|
|
? item.isOwn
|
|
? "oklch(0.60 0.16 160)"
|
|
: "oklch(0.75 0.08 160)"
|
|
: item.kind === "notdienst"
|
|
? item.isOwn
|
|
? "oklch(0.60 0.18 45)"
|
|
: "oklch(0.75 0.10 50)"
|
|
: 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 color =
|
|
item.kind === "termin"
|
|
? getCategoryColor(item.type)
|
|
: item.kind === "notdienst"
|
|
? item.isOwn
|
|
? "oklch(0.60 0.18 45)"
|
|
: "oklch(0.75 0.10 50)"
|
|
: item.kind === "duty"
|
|
? item.isOwn
|
|
? "oklch(0.60 0.16 160)"
|
|
: "oklch(0.75 0.08 160)"
|
|
: "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>
|
|
{item.kind === "termin" ? (
|
|
<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>
|
|
) : item.kind === "notdienst" ? (
|
|
<Badge
|
|
variant="secondary"
|
|
className="text-[10px]"
|
|
style={{
|
|
color,
|
|
borderColor: color + "44",
|
|
backgroundColor: color + "11",
|
|
}}
|
|
>
|
|
{item.isOwn ? "Mein Notdienst" : "Notdienst"}
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="secondary" className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)] text-[10px]">
|
|
Elterndienst
|
|
</Badge>
|
|
)}
|
|
{item.kind === "termin" && 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" />
|
|
{item.kind === "termin"
|
|
? item.allDay
|
|
? "Ganztägig"
|
|
: `${format(new Date(item.startDate), "HH:mm")} - ${format(
|
|
new Date(item.endDate),
|
|
"HH:mm",
|
|
)}`
|
|
: item.kind === "notdienst"
|
|
? `${format(new Date(item.startDate), "dd.MM.yyyy")} (ganztägig)`
|
|
: `${format(new Date(item.startDate), "dd.MM.")} - ${format(
|
|
new Date(item.endDate),
|
|
"dd.MM.yyyy",
|
|
)}`}
|
|
{item.kind === "termin" && <MapPin className="ml-1 h-3.5 w-3.5" />}
|
|
{item.kind === "termin"
|
|
? "Kita"
|
|
: item.kind === "notdienst"
|
|
? `Kind: ${item.childName} · Familie: ${item.familyName}`
|
|
: `Familie: ${item.familyName}`}
|
|
</p>
|
|
|
|
{item.kind === "termin" && item.description && (
|
|
<p className="mt-2 text-sm text-[var(--ink-soft)] line-clamp-2 leading-relaxed">
|
|
{item.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{item.kind === "termin" && item.rsvpEvent ? <RsvpDialog event={item.rsvpEvent} /> : null}
|
|
|
|
{/* Actions for admins */}
|
|
{isAdmin && item.kind === "termin" && (
|
|
<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";
|
|
}
|
|
}
|