import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Box, Typography, Button, IconButton, Chip, Tooltip, CircularProgress, Alert, Popover, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, TextField, Select, MenuItem, FormControl, InputLabel, FormControlLabel, Switch, Stack, List, ListItem, ListItemText, Divider, Paper, Skeleton, ButtonGroup, useTheme, useMediaQuery, Snackbar, Autocomplete, Radio, RadioGroup, } from '@mui/material'; import { Add, ChevronLeft, ChevronRight, CalendarViewMonth as CalendarIcon, ViewList as ListViewIcon, ContentCopy as CopyIcon, Cancel as CancelIcon, Edit as EditIcon, Today as TodayIcon, IosShare, Event as EventIcon, Delete as DeleteIcon, } from '@mui/icons-material'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import ChatAwareFab from '../components/shared/ChatAwareFab'; import GermanDateField from '../components/shared/GermanDateField'; import { toGermanDate, toGermanDateTime, fromGermanDate, fromGermanDateTime } from '../utils/dateInput'; import { usePermissionContext } from '../contexts/PermissionContext'; import { useNotification } from '../contexts/NotificationContext'; import { eventsApi } from '../services/events'; import type { VeranstaltungListItem, VeranstaltungKategorie, GroupInfo, CreateVeranstaltungInput, ConflictEvent, WiederholungConfig, } from '../types/events.types'; // --------------------------------------------------------------------------- // Constants & helpers // --------------------------------------------------------------------------- const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; const MONTH_LABELS = [ 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', ]; function startOfDay(d: Date): Date { const c = new Date(d); c.setHours(0, 0, 0, 0); return c; } function sameDay(a: Date, b: Date): boolean { return ( a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() ); } /** Returns calendar grid cells for the month view — always 6×7 (42 cells), starting Monday */ function buildMonthGrid(year: number, month: number): Date[] { const firstDay = new Date(year, month, 1); // ISO week starts Monday; getDay() returns 0=Sun → convert to Mon=0 const dayOfWeek = (firstDay.getDay() + 6) % 7; const start = new Date(firstDay); start.setDate(start.getDate() - dayOfWeek); const cells: Date[] = []; for (let i = 0; i < 42; i++) { const d = new Date(start); d.setDate(start.getDate() + i); cells.push(d); } return cells; } function formatTime(isoString: string): string { const d = new Date(isoString); const h = String(d.getHours()).padStart(2, '0'); const m = String(d.getMinutes()).padStart(2, '0'); return `${h}:${m}`; } function formatDateLong(d: Date): string { const days = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; return `${days[d.getDay()]}, ${d.getDate()}. ${MONTH_LABELS[d.getMonth()]} ${d.getFullYear()}`; } function formatDateShort(isoString: string): string { const d = new Date(isoString); return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`; } /** Convert datetime-local value back to ISO string */ function fromDatetimeLocal(value: string): string { if (!value) return new Date().toISOString(); // DD.MM.YYYY HH:MM format const dtIso = fromGermanDateTime(value); if (dtIso) return new Date(dtIso).toISOString(); // DD.MM.YYYY format (for ganztaegig) const dIso = fromGermanDate(value); if (dIso) return new Date(dIso).toISOString(); return new Date(value).toISOString(); } const EMPTY_FORM: CreateVeranstaltungInput = { titel: '', beschreibung: null, ort: null, ort_url: null, kategorie_id: null, datum_von: new Date().toISOString(), datum_bis: new Date().toISOString(), ganztaegig: false, zielgruppen: [], alle_gruppen: true, max_teilnehmer: null, anmeldung_erforderlich: false, anmeldung_bis: null, }; // --------------------------------------------------------------------------- // iCal Subscribe Dialog // --------------------------------------------------------------------------- interface IcalDialogProps { open: boolean; onClose: () => void; } function IcalDialog({ open, onClose }: IcalDialogProps) { const [snackOpen, setSnackOpen] = useState(false); const [subscribeUrl, setSubscribeUrl] = useState(null); const [loading, setLoading] = useState(false); const handleOpen = async () => { if (subscribeUrl) return; setLoading(true); try { const { subscribeUrl: url } = await eventsApi.getCalendarToken(); setSubscribeUrl(url); } catch (_) { setSubscribeUrl(null); } finally { setLoading(false); } }; const handleCopy = async () => { if (!subscribeUrl) return; await navigator.clipboard.writeText(subscribeUrl); setSnackOpen(true); }; return ( <> Kalender abonnieren Kopiere die URL und füge sie in deiner Kalender-App unter "Kalender abonnieren" ein. Der Kalender wird automatisch aktualisiert, sobald neue Veranstaltungen eingetragen werden. {loading && } {!loading && subscribeUrl && ( {subscribeUrl} )} Apple Kalender: Ablage → Neues Kalenderabonnement
Google Kalender: Andere Kalender → Per URL
Thunderbird: Neu → Kalender → Im Netzwerk
{subscribeUrl && ( )}
setSnackOpen(false)} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} > setSnackOpen(false)}> URL kopiert! ); } // --------------------------------------------------------------------------- // Month Calendar Grid // --------------------------------------------------------------------------- interface MonthCalendarProps { year: number; month: number; events: VeranstaltungListItem[]; selectedKategorie: string | 'all'; onDayClick: (day: Date, anchor: Element) => void; } function MonthCalendar({ year, month, events, selectedKategorie, onDayClick, }: MonthCalendarProps) { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const today = startOfDay(new Date()); const cells = useMemo(() => buildMonthGrid(year, month), [year, month]); // Build a map: "YYYY-MM-DD" → events (including multi-day events) const eventsByDay = useMemo(() => { const map = new Map(); for (const ev of events) { if (selectedKategorie !== 'all' && ev.kategorie_id !== selectedKategorie) continue; const start = startOfDay(new Date(ev.datum_von)); const end = startOfDay(new Date(ev.datum_bis)); // Add event to every day it spans const cur = new Date(start); while (cur <= end) { const key = cur.toISOString().slice(0, 10); const arr = map.get(key) ?? []; arr.push(ev); map.set(key, arr); cur.setDate(cur.getDate() + 1); } } return map; }, [events, selectedKategorie]); return ( {/* Weekday headers */} {WEEKDAY_LABELS.map((wd) => ( {wd} ))} {/* Day cells — 6 rows × 7 cols */} {cells.map((cell, idx) => { const isCurrentMonth = cell.getMonth() === month; const isTodayDate = sameDay(cell, today); const key = cell.toISOString().slice(0, 10); const dayEvents = eventsByDay.get(key) ?? []; const hasEvents = dayEvents.length > 0; const maxDots = isMobile ? 3 : 5; return ( hasEvents && onDayClick(cell, e.currentTarget)} sx={{ minHeight: isMobile ? 44 : 72, borderRadius: 1, p: '4px', cursor: hasEvents ? 'pointer' : 'default', bgcolor: isTodayDate ? 'primary.main' : isCurrentMonth ? 'background.paper' : 'action.disabledBackground', border: '1px solid', borderColor: isTodayDate ? 'primary.dark' : 'divider', transition: 'background 0.1s', '&:hover': hasEvents ? { bgcolor: isTodayDate ? 'primary.dark' : 'action.hover' } : {}, display: 'flex', flexDirection: 'column', alignItems: 'center', overflow: 'hidden', }} > {cell.getDate()} {/* Event dots */} {hasEvents && ( {dayEvents.slice(0, maxDots).map((ev, i) => ( ))} {dayEvents.length > maxDots && ( +{dayEvents.length - maxDots} )} )} {/* On desktop: show short event titles */} {!isMobile && hasEvents && ( {dayEvents.slice(0, 2).map((ev, i) => ( {ev.titel} ))} )} ); })} ); } // --------------------------------------------------------------------------- // Day Popover // --------------------------------------------------------------------------- interface DayPopoverProps { anchorEl: Element | null; day: Date | null; events: VeranstaltungListItem[]; onClose: () => void; onEdit: (ev: VeranstaltungListItem) => void; canWrite: boolean; } function DayPopover({ anchorEl, day, events, onClose, onEdit, canWrite }: DayPopoverProps) { if (!day) return null; return ( {formatDateLong(day)} {events.map((ev) => ( {ev.titel} {ev.abgesagt && ( )} } secondary={ {ev.ganztaegig ? ( Ganztägig ) : ( {formatTime(ev.datum_von)} – {formatTime(ev.datum_bis)} Uhr )} {ev.ort && ( {ev.ort} )} } /> {canWrite && !ev.abgesagt && ( { onEdit(ev); onClose(); }} sx={{ mt: 0.25 }} > )} ))} ); } // --------------------------------------------------------------------------- // Event Form Dialog // --------------------------------------------------------------------------- interface EventFormDialogProps { open: boolean; onClose: () => void; onSaved: () => void; editingEvent: VeranstaltungListItem | null; kategorien: VeranstaltungKategorie[]; groups: GroupInfo[]; } function EventFormDialog({ open, onClose, onSaved, editingEvent, kategorien, groups, }: EventFormDialogProps) { const notification = useNotification(); const [loading, setLoading] = useState(false); const [form, setForm] = useState({ ...EMPTY_FORM }); // Reset/populate form whenever dialog opens useEffect(() => { if (!open) return; if (editingEvent) { setForm({ titel: editingEvent.titel, beschreibung: editingEvent.beschreibung ?? null, ort: editingEvent.ort ?? null, ort_url: null, kategorie_id: editingEvent.kategorie_id ?? null, datum_von: editingEvent.datum_von, datum_bis: editingEvent.datum_bis, ganztaegig: editingEvent.ganztaegig, zielgruppen: editingEvent.zielgruppen, alle_gruppen: editingEvent.alle_gruppen, max_teilnehmer: null, anmeldung_erforderlich: editingEvent.anmeldung_erforderlich, anmeldung_bis: null, wiederholung: editingEvent.wiederholung ?? undefined, }); } else { const now = new Date(); now.setMinutes(0, 0, 0); const later = new Date(now); later.setHours(later.getHours() + 2); setForm({ ...EMPTY_FORM, datum_von: now.toISOString(), datum_bis: later.toISOString(), }); } }, [open, editingEvent]); // ----------------------------------------------------------------------- // Conflict detection — debounced check when dates change // ----------------------------------------------------------------------- const [conflicts, setConflicts] = useState([]); const conflictTimerRef = useRef | null>(null); useEffect(() => { // Clear conflicts when dialog closes if (!open) { setConflicts([]); return; } const vonDate = new Date(form.datum_von); const bisDate = new Date(form.datum_bis); if (isNaN(vonDate.getTime()) || isNaN(bisDate.getTime()) || bisDate <= vonDate) { setConflicts([]); return; } if (conflictTimerRef.current) clearTimeout(conflictTimerRef.current); conflictTimerRef.current = setTimeout(async () => { try { const result = await eventsApi.checkConflicts( vonDate.toISOString(), bisDate.toISOString(), editingEvent?.id ); setConflicts(result); } catch { // Silently ignore — conflict check is advisory only setConflicts([]); } }, 500); return () => { if (conflictTimerRef.current) clearTimeout(conflictTimerRef.current); }; }, [open, form.datum_von, form.datum_bis, editingEvent]); const handleChange = (field: keyof CreateVeranstaltungInput, value: unknown) => { if (field === 'kategorie_id' && !editingEvent) { // Auto-fill zielgruppen / alle_gruppen from the selected category (only for new events) const kat = kategorien.find((k) => k.id === value); if (kat) { setForm((prev) => ({ ...prev, kategorie_id: value as string | null, alle_gruppen: kat.alle_gruppen, zielgruppen: kat.alle_gruppen ? [] : kat.zielgruppen, })); return; } } setForm((prev) => ({ ...prev, [field]: value })); }; const handleSave = async () => { if (!form.titel.trim()) { notification.showError('Titel ist erforderlich'); return; } const vonDate = new Date(form.datum_von); const bisDate = new Date(form.datum_bis); if (isNaN(vonDate.getTime())) { notification.showError('Ungültiges Von-Datum'); return; } if (isNaN(bisDate.getTime())) { notification.showError('Ungültiges Bis-Datum'); return; } if (bisDate < vonDate) { notification.showError('Bis-Datum muss nach dem Von-Datum liegen'); return; } setLoading(true); try { if (editingEvent) { await eventsApi.updateEvent(editingEvent.id, form); notification.showSuccess('Veranstaltung aktualisiert'); } else { await eventsApi.createEvent(form); notification.showSuccess('Veranstaltung erstellt'); } onSaved(); onClose(); } catch (e: unknown) { const msg = e instanceof Error ? e.message : 'Fehler beim Speichern'; notification.showError(msg); } finally { setLoading(false); } }; return ( {editingEvent ? 'Veranstaltung bearbeiten' : 'Neue Veranstaltung'} {/* Titel */} handleChange('titel', e.target.value)} required fullWidth /> {/* Beschreibung */} handleChange('beschreibung', e.target.value || null)} multiline rows={3} fullWidth /> {/* Kategorie */} Kategorie {/* Ganztägig toggle */} handleChange('ganztaegig', e.target.checked)} /> } label="Ganztägig" /> {/* Datum von */} { const raw = e.target.value; const iso = form.ganztaegig ? fromDatetimeLocal(raw ? `${fromGermanDate(raw)}T00:00` : '') : fromDatetimeLocal(raw); handleChange('datum_von', iso); }} InputLabelProps={{ shrink: true }} fullWidth /> {/* Datum bis */} { const raw = e.target.value; const iso = form.ganztaegig ? fromDatetimeLocal(raw ? `${fromGermanDate(raw)}T23:59` : '') : fromDatetimeLocal(raw); handleChange('datum_bis', iso); }} InputLabelProps={{ shrink: true }} fullWidth /> {/* Conflict warning */} {conflicts.length > 0 && ( Überschneidung mit bestehenden Veranstaltungen: {conflicts.map((c) => { const von = new Date(c.datum_von); const bis = new Date(c.datum_bis); const fmtDate = (d: Date) => `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`; const fmtTime = (d: Date) => `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; const range = sameDay(von, bis) ? `${fmtDate(von)} ${fmtTime(von)} - ${fmtTime(bis)}` : `${fmtDate(von)} ${fmtTime(von)} - ${fmtDate(bis)} ${fmtTime(bis)}`; return ( {'\u2022'} "{c.titel}" ({range}) ); })} )} {/* Ort */} handleChange('ort', e.target.value || null)} fullWidth /> {/* Ort URL */} handleChange('ort_url', e.target.value || null)} fullWidth /> {/* Alle Gruppen toggle */} handleChange('alle_gruppen', e.target.checked)} /> } label="Für alle Mitglieder sichtbar" /> {/* Zielgruppen multi-select */} {!form.alle_gruppen && groups.length > 0 && ( option.label} value={groups.filter((g) => form.zielgruppen.includes(g.id))} onChange={(_, newValue) => { handleChange('zielgruppen', newValue.map((g) => g.id)); }} isOptionEqualToValue={(option, value) => option.id === value.id} renderInput={(params) => ( )} renderTags={(value, getTagProps) => value.map((option, index) => ( )) } size="small" disableCloseOnSelect /> )} {/* Anmeldung erforderlich */} handleChange('anmeldung_erforderlich', e.target.checked)} /> } label="Anmeldung erforderlich" /> {/* Anmeldung bis */} {form.anmeldung_erforderlich && ( { const raw = e.target.value; if (!raw) { handleChange('anmeldung_bis', null); return; } const iso = fromGermanDateTime(raw); if (iso) handleChange('anmeldung_bis', new Date(iso).toISOString()); }} InputLabelProps={{ shrink: true }} fullWidth /> )} {/* Max Teilnehmer */} handleChange('max_teilnehmer', e.target.value ? Number(e.target.value) : null)} inputProps={{ min: 1 }} fullWidth /> {/* Recurrence / Wiederholung — for new events, parent events, or child events */} {(!editingEvent || editingEvent.wiederholung || editingEvent.wiederholung_parent_id) && ( <> {editingEvent?.wiederholung_parent_id ? ( Dieser Termin ist Teil einer Serienveranstaltung. Änderungen betreffen nur diesen Einzeltermin. ) : editingEvent?.wiederholung ? ( Änderungen an der Wiederholung werden alle bestehenden Instanzen löschen und neu generieren. ) : null} {!editingEvent?.wiederholung_parent_id && { if (e.target.checked) { const bisDefault = new Date(form.datum_von); bisDefault.setMonth(bisDefault.getMonth() + 3); handleChange('wiederholung', { typ: 'wöchentlich', intervall: 1, bis: bisDefault.toISOString().slice(0, 10), } as WiederholungConfig); } else { handleChange('wiederholung', null); } }} /> } label="Wiederholung" />} {!editingEvent?.wiederholung_parent_id && form.wiederholung && ( Häufigkeit {form.wiederholung.typ === 'wöchentlich' && ( { const w = { ...form.wiederholung!, intervall: Math.max(1, Number(e.target.value) || 1) }; handleChange('wiederholung', w); }} inputProps={{ min: 1, max: 52 }} fullWidth /> )} {(form.wiederholung.typ === 'monatlich_erster_wochentag' || form.wiederholung.typ === 'monatlich_letzter_wochentag') && ( Wochentag )} { const w = { ...form.wiederholung!, bis: iso }; handleChange('wiederholung', w); }} fullWidth helperText="Enddatum der Wiederholungsserie" /> )} )} ); } // --------------------------------------------------------------------------- // List View // --------------------------------------------------------------------------- interface ListViewProps { events: VeranstaltungListItem[]; canWrite: boolean; onEdit: (ev: VeranstaltungListItem) => void; onCancel: (id: string) => void; onDelete: (id: string) => void; } function EventListView({ events, canWrite, onEdit, onCancel, onDelete }: ListViewProps) { if (events.length === 0) { return ( Keine Veranstaltungen in diesem Zeitraum. ); } return ( {events.map((ev, idx) => ( {idx > 0 && } {/* Date badge */} {new Date(ev.datum_von).getDate()}. {new Date(ev.datum_von).getMonth() + 1}. {ev.ganztaegig ? ( Ganztägig ) : ( {formatTime(ev.datum_von)} )} {ev.titel} {ev.abgesagt && ( )} } secondary={ {ev.kategorie_name && ( )} {ev.ort && ( {ev.ort} )} {!ev.ganztaegig && ( bis {formatDateShort(ev.datum_bis)} {formatTime(ev.datum_bis)} Uhr )} } sx={{ my: 0 }} /> {canWrite && !ev.abgesagt && ( onEdit(ev)}> onCancel(ev.id)}> onDelete(ev.id)}> )} ))} ); } // --------------------------------------------------------------------------- // Main Page // --------------------------------------------------------------------------- export default function Veranstaltungen() { const { hasPermission } = usePermissionContext(); const notification = useNotification(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const canWrite = hasPermission('kalender:create'); const today = new Date(); const [viewMonth, setViewMonth] = useState({ year: today.getFullYear(), month: today.getMonth() }); const [viewMode, setViewMode] = useState<'calendar' | 'list'>('calendar'); const [selectedKategorie, setSelectedKategorie] = useState('all'); // Data const [events, setEvents] = useState([]); const [kategorien, setKategorien] = useState([]); const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Popover state const [popoverAnchor, setPopoverAnchor] = useState(null); const [popoverDay, setPopoverDay] = useState(null); const [popoverEvents, setPopoverEvents] = useState([]); // Event form dialog const [formOpen, setFormOpen] = useState(false); const [editingEvent, setEditingEvent] = useState(null); // Cancel dialog const [cancelId, setCancelId] = useState(null); const [cancelGrund, setCancelGrund] = useState(''); const [cancelLoading, setCancelLoading] = useState(false); // Delete dialog const [deleteId, setDeleteId] = useState(null); const [deleteLoading, setDeleteLoading] = useState(false); const [deleteMode, setDeleteMode] = useState<'all' | 'single' | 'future'>('all'); // iCal dialog const [icalOpen, setIcalOpen] = useState(false); // --------------------------------------------------------------------------- // Data loading // --------------------------------------------------------------------------- const loadData = useCallback(async () => { setLoading(true); setError(null); try { // Compute grid range (same as buildMonthGrid) const firstDay = new Date(viewMonth.year, viewMonth.month, 1); const dayOfWeek = (firstDay.getDay() + 6) % 7; const gridStart = new Date(firstDay); gridStart.setDate(gridStart.getDate() - dayOfWeek); const gridEnd = new Date(gridStart); gridEnd.setDate(gridStart.getDate() + 41); const [eventsData, kategorienData, groupsData] = await Promise.all([ eventsApi.getCalendarRange(gridStart, gridEnd), eventsApi.getKategorien(), eventsApi.getGroups(), ]); setEvents(eventsData); setKategorien(kategorienData); setGroups(groupsData); } catch (e: unknown) { const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Veranstaltungen'; setError(msg); } finally { setLoading(false); } }, [viewMonth]); useEffect(() => { loadData(); }, [loadData]); // --------------------------------------------------------------------------- // Navigation // --------------------------------------------------------------------------- const handlePrev = () => { setViewMonth((prev) => { const m = prev.month === 0 ? 11 : prev.month - 1; const y = prev.month === 0 ? prev.year - 1 : prev.year; return { year: y, month: m }; }); }; const handleNext = () => { setViewMonth((prev) => { const m = prev.month === 11 ? 0 : prev.month + 1; const y = prev.month === 11 ? prev.year + 1 : prev.year; return { year: y, month: m }; }); }; const handleToday = () => { const now = new Date(); setViewMonth({ year: now.getFullYear(), month: now.getMonth() }); }; // --------------------------------------------------------------------------- // Day popover // --------------------------------------------------------------------------- const handleDayClick = useCallback( (day: Date, anchor: Element) => { const key = day.toISOString().slice(0, 10); const dayEvs = events.filter((ev) => { if (selectedKategorie !== 'all' && ev.kategorie_id !== selectedKategorie) return false; const start = startOfDay(new Date(ev.datum_von)).toISOString().slice(0, 10); const end = startOfDay(new Date(ev.datum_bis)).toISOString().slice(0, 10); return key >= start && key <= end; }); setPopoverDay(day); setPopoverAnchor(anchor); setPopoverEvents(dayEvs); }, [events, selectedKategorie] ); // --------------------------------------------------------------------------- // Cancel event // --------------------------------------------------------------------------- const handleCancelEvent = async () => { if (!cancelId || cancelGrund.trim().length < 5) return; setCancelLoading(true); try { await eventsApi.cancelEvent(cancelId, cancelGrund.trim()); notification.showSuccess('Veranstaltung wurde abgesagt'); setCancelId(null); setCancelGrund(''); loadData(); } catch (e: unknown) { const msg = e instanceof Error ? e.message : 'Fehler beim Absagen'; notification.showError(msg); } finally { setCancelLoading(false); } }; const handleDeleteEvent = async () => { if (!deleteId) return; setDeleteLoading(true); try { await eventsApi.deleteEvent(deleteId, deleteMode); setDeleteId(null); setDeleteMode('all'); loadData(); notification.showSuccess('Veranstaltung wurde gelöscht'); } catch (e: unknown) { const msg = e instanceof Error ? e.message : 'Fehler beim Löschen'; notification.showError(msg); } finally { setDeleteLoading(false); } }; // --------------------------------------------------------------------------- // Filtered events for list view // --------------------------------------------------------------------------- const filteredListEvents = useMemo(() => { return events .filter((ev) => { if (selectedKategorie !== 'all' && ev.kategorie_id !== selectedKategorie) return false; const d = new Date(ev.datum_von); return d.getMonth() === viewMonth.month && d.getFullYear() === viewMonth.year; }) .sort((a, b) => a.datum_von.localeCompare(b.datum_von)); }, [events, selectedKategorie, viewMonth]); // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- return ( {/* Page header */} Veranstaltungen {/* View toggle */} {/* Category filter chips */} setSelectedKategorie('all')} color={selectedKategorie === 'all' ? 'primary' : 'default'} variant={selectedKategorie === 'all' ? 'filled' : 'outlined'} size="small" /> {kategorien.map((k) => ( setSelectedKategorie(selectedKategorie === k.id ? 'all' : k.id)} size="small" sx={{ bgcolor: selectedKategorie === k.id ? k.farbe : undefined, color: selectedKategorie === k.id ? 'white' : undefined, '&:hover': { opacity: 0.85 }, }} variant={selectedKategorie === k.id ? 'filled' : 'outlined'} /> ))} {/* Month navigation */} {MONTH_LABELS[viewMonth.month]} {viewMonth.year} {/* Error */} {error && ( setError(null)}> {error} )} {/* Calendar / List body */} {loading ? ( ) : viewMode === 'calendar' ? ( ) : ( { setEditingEvent(ev); setFormOpen(true); }} onCancel={(id) => { setCancelId(id); setCancelGrund(''); }} onDelete={(id) => { const ev = events.find((e) => e.id === id); const isRecurring = ev && (ev.wiederholung_parent_id || ev.wiederholung); setDeleteMode(isRecurring ? 'single' : 'all'); setDeleteId(id); }} /> )} {/* FAB for creating events */} {canWrite && ( { setEditingEvent(null); setFormOpen(true); }} > )} {/* Day Popover */} setPopoverAnchor(null)} onEdit={(ev) => { setEditingEvent(ev); setFormOpen(true); }} canWrite={canWrite} /> {/* Create/Edit Event Dialog */} { setFormOpen(false); setEditingEvent(null); }} onSaved={loadData} editingEvent={editingEvent} kategorien={kategorien} groups={groups} /> {/* Cancel Dialog */} setCancelId(null)} maxWidth="xs" fullWidth > Veranstaltung stornieren Bitte gib einen Grund für die Stornierung an (mind. 5 Zeichen). setCancelGrund(e.target.value)} autoFocus /> {/* Delete Dialog */} { setDeleteId(null); setDeleteMode('all'); }} maxWidth="xs" fullWidth> Veranstaltung endgültig löschen {(() => { const deleteEvent = events.find((ev) => ev.id === deleteId); const isRecurring = deleteEvent && (deleteEvent.wiederholung_parent_id || deleteEvent.wiederholung); if (isRecurring) { return ( <> Diese Veranstaltung ist Teil einer Wiederholungsserie. Was soll gelöscht werden? setDeleteMode(e.target.value as 'all' | 'single' | 'future')} > } label="Nur diesen Termin" /> } label="Diesen und alle folgenden Termine" /> } label="Alle Termine der Serie" /> ); } return ( Soll diese Veranstaltung wirklich endgültig gelöscht werden? Diese Aktion kann nicht rückgängig gemacht werden. ); })()} {/* iCal Subscribe Dialog */} setIcalOpen(false)} /> ); }