"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import FullCalendar from "@fullcalendar/react"; import dayGridPlugin from "@fullcalendar/daygrid"; import timeGridPlugin from "@fullcalendar/timegrid"; import listPlugin from "@fullcalendar/list"; import interactionPlugin from "@fullcalendar/interaction"; import deLocale from "@fullcalendar/core/locales/de"; import { useSession } from "next-auth/react"; import EventForm from "./EventForm"; type EventItem = { id: string; title: string; description?: string | null; location?: string | null; locationPlaceId?: string | null; locationLat?: number | null; locationLng?: number | null; startAt: string; endAt?: string | null; status: string; category?: { id: string; name: string } | null; }; type ViewItem = { id: string; token: string; items: { eventId: string }[]; categories: { categoryId: string; category?: { name: string } }[]; exclusions: { eventId: string }[]; }; export default function CalendarBoard() { const { data } = useSession(); const [events, setEvents] = useState([]); const [view, setView] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [query, setQuery] = useState(""); const [categoryFilter, setCategoryFilter] = useState("ALL"); const [sortKey, setSortKey] = useState("startAt"); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); const [detailsEvent, setDetailsEvent] = useState(null); const [placesKey, setPlacesKey] = useState(""); const [placesProvider, setPlacesProvider] = useState<"google" | "osm">("osm"); const [formOpen, setFormOpen] = useState(false); const [prefillStartAt, setPrefillStartAt] = useState(null); const [viewOrder, setViewOrder] = useState>([ "calendar", "list" ]); const [collapsed, setCollapsed] = useState<{ calendar: boolean; list: boolean }>({ calendar: false, list: false }); const [activeRange, setActiveRange] = useState<{ start: Date; end: Date; viewType: string; } | null>(null); const [initialView, setInitialView] = useState("dayGridMonth"); const [dragSource, setDragSource] = useState<"calendar" | "list" | null>(null); const [dragOver, setDragOver] = useState<"calendar" | "list" | null>(null); const calendarRef = useRef(null); const listRef = useRef(null); const fetchEvents = async () => { setLoading(true); setError(null); try { const [eventsResponse, viewResponse] = await Promise.all([ fetch("/api/events"), fetch("/api/views/default") ]); if (!eventsResponse.ok) { throw new Error("Events konnten nicht geladen werden."); } const payload = await eventsResponse.json(); setEvents(payload); if (viewResponse.ok) { setView(await viewResponse.json()); } } catch (err) { setError((err as Error).message); } finally { setLoading(false); } }; useEffect(() => { if (data?.user) { fetchEvents(); } }, [data?.user]); useEffect(() => { const loadKey = async () => { try { const response = await fetch("/api/settings/google-places"); if (!response.ok) return; const payload = await response.json(); setPlacesKey(payload.apiKey || ""); setPlacesProvider(payload.provider === "google" ? "google" : "osm"); } catch { // ignore } }; if (data?.user) { loadKey(); } }, [data?.user]); useEffect(() => { const handler = () => { fetchEvents(); }; window.addEventListener("views-updated", handler); return () => window.removeEventListener("views-updated", handler); }, []); useEffect(() => { try { const stored = window.localStorage.getItem("calendarLastView"); if (stored) { setInitialView(stored); } } catch { // ignore } }, []); useEffect(() => { try { const stored = window.localStorage.getItem("listSort"); if (!stored) return; const parsed = JSON.parse(stored); if ( parsed && typeof parsed === "object" && typeof parsed.key === "string" && (parsed.dir === "asc" || parsed.dir === "desc") ) { setSortKey(parsed.key); setSortDir(parsed.dir); } } catch { // ignore } }, []); useEffect(() => { try { const stored = window.localStorage.getItem("calendarViewOrder"); if (!stored) return; const parsed = JSON.parse(stored); if ( Array.isArray(parsed) && parsed.length === 2 && parsed.includes("calendar") && parsed.includes("list") ) { setViewOrder(parsed); } } catch { // ignore } }, []); useEffect(() => { try { const stored = window.localStorage.getItem("calendarViewCollapsed"); if (!stored) return; const parsed = JSON.parse(stored); if ( parsed && typeof parsed === "object" && typeof parsed.calendar === "boolean" && typeof parsed.list === "boolean" ) { setCollapsed({ calendar: parsed.calendar, list: parsed.list }); } } catch { // ignore } }, []); const persistOrder = (nextOrder: Array<"calendar" | "list">) => { setViewOrder(nextOrder); try { window.localStorage.setItem("calendarViewOrder", JSON.stringify(nextOrder)); } catch { // ignore } }; const toggleCollapse = (section: "calendar" | "list") => { const next = { ...collapsed, [section]: !collapsed[section] }; setCollapsed(next); try { window.localStorage.setItem("calendarViewCollapsed", JSON.stringify(next)); } catch { // ignore } }; const swapOrder = (source: "calendar" | "list", target: "calendar" | "list") => { if (source === target) return; const next = viewOrder.map((item) => (item === source ? target : source)) as Array< "calendar" | "list" >; persistOrder(next); }; const onDragStart = ( event: React.DragEvent, section: "calendar" | "list" ) => { event.dataTransfer.setData("text/plain", section); event.dataTransfer.effectAllowed = "move"; setDragSource(section); setDragOver(section); const node = section === "calendar" ? calendarRef.current : listRef.current; if (node) { event.dataTransfer.setDragImage(node, 20, 20); } }; const onDragEnter = (target: "calendar" | "list") => { setDragOver(target); }; const onDragEnd = () => { setDragSource(null); setDragOver(null); }; const onDrop = (event: React.DragEvent, target: "calendar" | "list") => { event.preventDefault(); const source = event.dataTransfer.getData("text/plain") as | "calendar" | "list"; if (source) { swapOrder(source, target); } setDragSource(null); setDragOver(null); }; const openFormForDate = (date: Date | null) => { if (date) { const prefill = new Date(date); prefill.setHours(12, 0, 0, 0); setPrefillStartAt(prefill.toISOString()); } else { setPrefillStartAt(null); } setFormOpen(true); }; const calendarEvents = useMemo( () => events.map((event) => ({ id: event.id, title: event.title, start: event.startAt, end: event.endAt || undefined, extendedProps: { status: event.status, location: event.location, description: event.description, category: event.category?.name } })), [events] ); const selectedEventIds = useMemo(() => { if (!view) return new Set(); const ids = new Set(view.items.map((item) => item.eventId)); const excluded = new Set(view.exclusions.map((item) => item.eventId)); const subscribedCategoryIds = new Set( view.categories.map((item) => item.categoryId) ); events.forEach((event) => { if (event.category?.id && subscribedCategoryIds.has(event.category.id)) { ids.add(event.id); } }); excluded.forEach((eventId) => ids.delete(eventId)); return ids; }, [view, events]); const subscribedCategoryIds = useMemo(() => { return new Set(view?.categories.map((item) => item.categoryId) || []); }, [view]); const toggleEvent = async (eventId: string) => { if (!view) return; const isSelected = selectedEventIds.has(eventId); await fetch(`/api/views/${view.id}/items`, { method: isSelected ? "DELETE" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ eventId }) }); await fetchEvents(); window.dispatchEvent(new Event("views-updated")); }; const filteredEvents = useMemo(() => { const normalizedQuery = query.trim().toLowerCase(); const filtered = events.filter((event) => { const categoryName = event.category?.name || "Ohne Kategorie"; if (categoryFilter !== "ALL" && categoryName !== categoryFilter) { return false; } if (!normalizedQuery) return true; return ( event.title.toLowerCase().includes(normalizedQuery) || (event.description || "").toLowerCase().includes(normalizedQuery) || (event.location || "").toLowerCase().includes(normalizedQuery) || categoryName.toLowerCase().includes(normalizedQuery) ); }); const sorted = [...filtered].sort((a, b) => { const dir = sortDir === "asc" ? 1 : -1; if (sortKey === "title") { return a.title.localeCompare(b.title) * dir; } if (sortKey === "category") { const aCat = a.category?.name || "Ohne Kategorie"; const bCat = b.category?.name || "Ohne Kategorie"; return aCat.localeCompare(bCat) * dir; } if (sortKey === "location") { const aLoc = a.location || ""; const bLoc = b.location || ""; return aLoc.localeCompare(bLoc) * dir; } if (sortKey === "status") { return a.status.localeCompare(b.status) * dir; } const aDate = new Date(a.startAt).getTime(); const bDate = new Date(b.startAt).getTime(); return (aDate - bDate) * dir; }); return sorted; }, [events, query, categoryFilter, sortKey, sortDir]); const categoryOptions = useMemo(() => { const list = events .map((event) => event.category?.name || "Ohne Kategorie") .filter(Boolean); return Array.from(new Set(list)).sort(); }, [events]); const displayedEvents = useMemo(() => { if (!activeRange) { return filteredEvents; } return filteredEvents.filter((event) => { const start = new Date(event.startAt); return start >= activeRange.start && start < activeRange.end; }); }, [filteredEvents, activeRange]); const toggleSort = (nextKey: string) => { let nextDir: "asc" | "desc" = "asc"; if (sortKey === nextKey) { nextDir = sortDir === "asc" ? "desc" : "asc"; setSortDir(nextDir); } else { setSortKey(nextKey); setSortDir("asc"); } try { const key = sortKey === nextKey ? sortKey : nextKey; window.localStorage.setItem( "listSort", JSON.stringify({ key, dir: nextDir }) ); } catch { // ignore } }; if (!data?.user) { return (

Bitte anmelden, um die Vereinskalender zu sehen.

); } return (

Kalender

Termine im Blick

{ setFormOpen(open); if (!open) { setPrefillStartAt(null); } }} prefillStartAt={prefillStartAt} /> {error &&

{error}

} {loading &&

Lade Termine...

} {viewOrder.map((section) => { if (section === "calendar") { return (
event.preventDefault()} onDragEnter={() => onDragEnter("calendar")} onDrop={(event) => onDrop(event, "calendar")} >

Kalender

onDragStart(event, "calendar")} onDragEnd={onDragEnd} title="Zum Tauschen ziehen" aria-label="Kalender verschieben" >
{!collapsed.calendar && ( { if (arg.view.type !== "dayGridMonth") { return arg.dayNumberText; } return (
{arg.dayNumberText}
); }} eventClick={(info) => { const match = events.find((event) => event.id === info.event.id); if (match) { setDetailsEvent(match); } }} eventContent={(arg) => { const status = arg.event.extendedProps.status as string; const isSelected = selectedEventIds.has(arg.event.id); return (
{arg.event.title}
{arg.event.extendedProps.category || "Ohne Kategorie"}
{ event.preventDefault(); event.stopPropagation(); toggleEvent(arg.event.id); }} size="sm" />
); }} height="auto" datesSet={(arg) => { setActiveRange({ start: arg.start, end: arg.end, viewType: arg.view.type }); try { window.localStorage.setItem("calendarLastView", arg.view.type); } catch { // ignore } }} /> )}
); } return (
event.preventDefault()} onDragEnter={() => onDragEnter("list")} onDrop={(event) => onDrop(event, "list")} >

Liste

onDragStart(event, "list")} onDragEnd={onDragEnd} title="Zum Tauschen ziehen" aria-label="Liste verschieben" >
{!collapsed.list && ( <>
setQuery(event.target.value)} placeholder="Suchen..." className="min-w-[220px] rounded-xl border border-slate-300 px-3 py-2 text-sm" />
{displayedEvents.length === 0 ? ( ) : ( displayedEvents.map((event) => ( )) )}
toggleSort("startAt")} /> toggleSort("title")} />
toggleSort("category")} />
toggleSort("location")} /> toggleSort("status")} /> Aktionen
Keine Termine für die aktuelle Auswahl.
{new Date(event.startAt).toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" })} {event.title} {event.category?.name || "Ohne Kategorie"}
{formatLocation(event.location)} {event.locationLat && event.locationLng && ( )}
toggleEvent(event.id)} />
)}
); })} {detailsEvent && (

Termin Details

Titel

{detailsEvent.title}

Zeitpunkt

{new Date(detailsEvent.startAt).toLocaleString("de-DE", { dateStyle: "full", timeStyle: "short" })}

Ort

{detailsEvent.location || "-"}

Kategorie

{detailsEvent.category?.name || "Ohne Kategorie"}

{detailsEvent.description && (

Beschreibung

{detailsEvent.description}

)} {detailsEvent.locationLat && detailsEvent.locationLng && ( )}
{placesProvider === "google" && placesKey && (detailsEvent.locationPlaceId || (detailsEvent.locationLat && detailsEvent.locationLng)) ? (