import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Alert, Box, Button, ButtonGroup, Checkbox, Chip, CircularProgress, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Divider, FormControl, FormControlLabel, FormGroup, IconButton, InputLabel, List, ListItem, ListItemText, MenuItem, Paper, Popover, Radio, RadioGroup, Select, Skeleton, Stack, Switch, Tab, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Tabs, TextField, Tooltip, Typography, useMediaQuery, useTheme, } from '@mui/material'; import { Add, Cancel as CancelIcon, CalendarViewMonth as CalendarIcon, CheckCircle as CheckIcon, ChevronLeft, ChevronRight, ContentCopy as CopyIcon, DeleteForever as DeleteForeverIcon, Edit as EditIcon, FileDownload as FileDownloadIcon, FileUpload as FileUploadIcon, HelpOutline as UnknownIcon, IosShare, PictureAsPdf as PdfIcon, Settings as SettingsIcon, Star as StarIcon, Today as TodayIcon, ViewList as ListViewIcon, ViewDay as ViewDayIcon, ViewWeek as ViewWeekIcon, } from '@mui/icons-material'; import { useNavigate, useSearchParams } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import ServiceModePage from '../components/shared/ServiceModePage'; import ChatAwareFab from '../components/shared/ChatAwareFab'; import GermanDateField from '../components/shared/GermanDateField'; import { toGermanDateTime, fromGermanDate, fromGermanDateTime } from '../utils/dateInput'; import { usePermissionContext } from '../contexts/PermissionContext'; import { useNotification } from '../contexts/NotificationContext'; import { trainingApi } from '../services/training'; import { eventsApi } from '../services/events'; import { configApi, type PdfSettings } from '../services/config'; import { addPdfHeader, addPdfFooter } from '../utils/pdfExport'; import type { UebungListItem, UebungTyp, TeilnahmeStatus, } from '../types/training.types'; import type { VeranstaltungListItem, VeranstaltungKategorie, GroupInfo, CreateVeranstaltungInput, WiederholungConfig, } from '../types/events.types'; import { format as fnsFormat, startOfWeek, endOfWeek, startOfMonth, endOfMonth, addDays, subDays, addWeeks, subWeeks, eachDayOfInterval, parseISO, } from 'date-fns'; import { de } from 'date-fns/locale'; // ────────────────────────────────────────────────────────────────────────────── // Constants // ────────────────────────────────────────────────────────────────────────────── 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', ]; const TYP_DOT_COLOR: Record = { 'Übungsabend': '#1976d2', 'Lehrgang': '#7b1fa2', 'Sonderdienst': '#e65100', 'Versammlung': '#616161', 'Gemeinschaftsübung': '#00796b', 'Sonstiges': '#9e9e9e', }; const EMPTY_VERANSTALTUNG_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, }; // ────────────────────────────────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────────────────────────────────── 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() ); } function buildMonthGrid(year: number, month: number): Date[] { const firstDay = new Date(year, month, 1); 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); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; } 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()}`; } // ────────────────────────────────────────────────────────────────────────────── // Types for unified calendar // ────────────────────────────────────────────────────────────────────────────── interface TrainingDayEvent { kind: 'training'; id: string; color: string; titel: string; abgesagt: boolean; pflicht: boolean; datum_von: string; } interface VeranstaltungDayEvent { kind: 'event'; id: string; color: string; titel: string; abgesagt: boolean; datum_von: string; } type CalDayEvent = TrainingDayEvent | VeranstaltungDayEvent; // ────────────────────────────────────────────────────────────────────────────── // RSVP dot // ────────────────────────────────────────────────────────────────────────────── function RsvpDot({ status }: { status: TeilnahmeStatus | undefined }) { if (!status || status === 'unbekannt') return ; if (status === 'zugesagt' || status === 'erschienen') return ; return ; } // ────────────────────────────────────────────────────────────────────────────── // Month Calendar (training + events) // ────────────────────────────────────────────────────────────────────────────── interface MonthCalendarProps { year: number; month: number; trainingEvents: UebungListItem[]; veranstaltungen: VeranstaltungListItem[]; selectedKategorie: string | 'all'; onDayClick: (day: Date, anchor: Element) => void; } function MonthCalendar({ year, month, trainingEvents, veranstaltungen, selectedKategorie, onDayClick, }: MonthCalendarProps) { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const todayDate = startOfDay(new Date()); const cells = useMemo(() => buildMonthGrid(year, month), [year, month]); const eventsByDay = useMemo(() => { const map = new Map(); for (const t of trainingEvents) { const key = startOfDay(new Date(t.datum_von)).toISOString().slice(0, 10); const arr = map.get(key) ?? []; arr.push({ kind: 'training', id: t.id, color: TYP_DOT_COLOR[t.typ], titel: t.titel, abgesagt: t.abgesagt, pflicht: t.pflichtveranstaltung, datum_von: t.datum_von, }); map.set(key, arr); } for (const ev of veranstaltungen) { if (selectedKategorie !== 'all' && ev.kategorie_id !== selectedKategorie) continue; const start = startOfDay(new Date(ev.datum_von)); const end = startOfDay(new Date(ev.datum_bis)); const cur = new Date(start); while (cur <= end) { const key = cur.toISOString().slice(0, 10); const arr = map.get(key) ?? []; arr.push({ kind: 'event', id: ev.id, color: ev.kategorie_farbe ?? '#1976d2', titel: ev.titel, abgesagt: ev.abgesagt, datum_von: ev.datum_von, }); map.set(key, arr); cur.setDate(cur.getDate() + 1); } } return map; }, [trainingEvents, veranstaltungen, selectedKategorie]); return ( {WEEKDAY_LABELS.map((wd) => ( {wd} ))} {cells.map((cell, idx) => { const isCurrentMonth = cell.getMonth() === month; const isTodayCell = sameDay(cell, todayDate); 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: isTodayCell ? 'primary.main' : isCurrentMonth ? 'background.paper' : 'action.disabledBackground', border: '1px solid', borderColor: isTodayCell ? 'primary.dark' : 'divider', transition: 'background 0.1s', '&:hover': hasEvents ? { bgcolor: isTodayCell ? 'primary.dark' : 'action.hover' } : {}, display: 'flex', flexDirection: 'column', alignItems: 'center', overflow: 'hidden', }} > {cell.getDate()} {hasEvents && ( {dayEvents.slice(0, maxDots).map((ev, i) => ( ))} {dayEvents.length > maxDots && ( +{dayEvents.length - maxDots} )} )} {!isMobile && hasEvents && ( {dayEvents.slice(0, 2).map((ev, i) => ( {ev.kind === 'training' && ev.pflicht && '* '} {ev.titel} ))} )} ); })} ); } // ────────────────────────────────────────────────────────────────────────────── // Day Popover (unified) // ────────────────────────────────────────────────────────────────────────────── interface DayPopoverProps { anchorEl: Element | null; day: Date | null; trainingForDay: UebungListItem[]; eventsForDay: VeranstaltungListItem[]; canWriteEvents: boolean; onClose: () => void; onTrainingClick: (id: string) => void; onEventEdit: (ev: VeranstaltungListItem) => void; onEventDelete: (id: string) => void; } function DayPopover({ anchorEl, day, trainingForDay, eventsForDay, canWriteEvents, onClose, onTrainingClick, onEventEdit, onEventDelete, }: DayPopoverProps) { if (!day) return null; const hasContent = trainingForDay.length > 0 || eventsForDay.length > 0; return ( {formatDateLong(day)} {!hasContent && ( Keine Einträge )} {trainingForDay.length > 0 && ( <> Dienste {trainingForDay.map((t) => ( { onTrainingClick(t.id); onClose(); }} sx={{ cursor: 'pointer', borderRadius: 1, px: 0.75, '&:hover': { bgcolor: 'action.hover' }, opacity: t.abgesagt ? 0.6 : 1, }} > {t.pflichtveranstaltung && ( )} {t.titel} } secondary={`${formatTime(t.datum_von)} – ${formatTime(t.datum_bis)} Uhr`} /> ))} )} {trainingForDay.length > 0 && eventsForDay.length > 0 && ( )} {eventsForDay.length > 0 && ( <> Veranstaltungen {eventsForDay.map((ev) => ( {ev.titel} {ev.abgesagt && ( )} } secondary={ ev.ganztaegig ? 'Ganztägig' : `${formatTime(ev.datum_von)} – ${formatTime(ev.datum_bis)} Uhr` } /> {canWriteEvents && !ev.abgesagt && ( { onEventEdit(ev); onClose(); }} sx={{ mt: 0.25 }} > )} {canWriteEvents && ( { onEventDelete(ev.id); onClose(); }} > )} ))} )} ); } let _pdfSettingsCache: PdfSettings | null = null; let _pdfSettingsCacheTime = 0; async function fetchPdfSettings(): Promise { // Cache for 30 seconds to avoid fetching on every export click if (_pdfSettingsCache && Date.now() - _pdfSettingsCacheTime < 30_000) { return _pdfSettingsCache; } try { _pdfSettingsCache = await configApi.getPdfSettings(); _pdfSettingsCacheTime = Date.now(); return _pdfSettingsCache; } catch { return { pdf_header: '', pdf_footer: '', pdf_logo: '', pdf_org_name: '' }; } } async function generatePdf( year: number, month: number, trainingEvents: UebungListItem[], veranstaltungen: VeranstaltungListItem[], ) { // Dynamically import jsPDF to avoid bundle bloat if not needed const { jsPDF } = await import('jspdf'); const autoTable = (await import('jspdf-autotable')).default; const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' }); const monthLabel = MONTH_LABELS[month]; const pdfSettings = await fetchPdfSettings(); // Header let tableStartY = await addPdfHeader(doc, pdfSettings, 297); // Document title below header const titleText = `Kalender — ${monthLabel} ${year}`; doc.setFontSize(14); doc.setFont('helvetica', 'bold'); doc.text(titleText, 10, tableStartY); tableStartY += 10; // Build combined list (same logic as CombinedListView) type ListEntry = | { kind: 'training'; item: UebungListItem } | { kind: 'event'; item: VeranstaltungListItem }; const combined: ListEntry[] = [ ...trainingEvents.map((t): ListEntry => ({ kind: 'training', item: t })), ...veranstaltungen.map((e): ListEntry => ({ kind: 'event', item: e })), ].sort((a, b) => a.item.datum_von.localeCompare(b.item.datum_von)); const formatDateCell = (iso: string) => { const d = new Date(iso); return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`; }; const formatTimeCell = (iso: string) => { const d = new Date(iso); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; }; const rows = combined.map((entry) => { const item = entry.item; return [ formatDateCell(item.datum_von), formatTimeCell(item.datum_von), item.titel, entry.kind === 'training' ? (item as UebungListItem).typ : ((item as VeranstaltungListItem).kategorie_name ?? 'Veranstaltung'), (item as any).ort ?? '', ]; }); autoTable(doc, { head: [['Datum', 'Uhrzeit', 'Titel', 'Kategorie/Typ', 'Ort']], body: rows, startY: tableStartY, headStyles: { fillColor: [183, 28, 28], textColor: 255, fontStyle: 'bold' }, alternateRowStyles: { fillColor: [250, 235, 235] }, margin: { left: 10, right: 10 }, styles: { fontSize: 9, cellPadding: 2 }, columnStyles: { 0: { cellWidth: 25 }, 1: { cellWidth: 18 }, 2: { cellWidth: 90 }, 3: { cellWidth: 40 }, 4: { cellWidth: 60 }, }, didDrawPage: addPdfFooter(doc, pdfSettings), }); const filename = `kalender_${year}_${String(month + 1).padStart(2, '0')}.pdf`; doc.save(filename); } // ────────────────────────────────────────────────────────────────────────────── // CSV Import Dialog // ────────────────────────────────────────────────────────────────────────────── const CSV_EXAMPLE = [ 'Titel;Datum Von;Datum Bis;Ganztaegig;Ort;Kategorie;Beschreibung', 'Übung Atemschutz;15.03.2026 19:00;15.03.2026 21:00;Nein;Feuerwehrhaus;Übung;Atemschutzübung für alle', 'Tag der offenen Tür;20.04.2026;20.04.2026;Ja;Feuerwehrhaus;Veranstaltung;', ].join('\n'); interface CsvRow { titel: string; datum_von: string; datum_bis: string; ganztaegig: boolean; ort: string | null; beschreibung: string | null; valid: boolean; error?: string; } function parseCsvRow(line: string, lineNo: number): CsvRow { const parts = line.split(';'); if (parts.length < 4) { return { titel: '', datum_von: '', datum_bis: '', ganztaegig: false, ort: null, beschreibung: null, valid: false, error: `Zeile ${lineNo}: Zu wenige Spalten` }; } const [titel, rawVon, rawBis, rawGanztaegig, ort, ...rest] = parts; // Support both 7-column (with Kategorie) and 6-column (without) CSVs: // 7 cols: Titel;Von;Bis;Ganztaegig;Ort;Kategorie;Beschreibung // 6 cols: Titel;Von;Bis;Ganztaegig;Ort;Beschreibung const beschreibung = rest.length >= 2 ? rest[1] : rest[0]; const ganztaegig = rawGanztaegig?.trim().toLowerCase() === 'ja'; const convertDate = (raw: string): string => { const trimmed = raw.trim(); // DD.MM.YYYY HH:MM const dtIso = fromGermanDateTime(trimmed); if (dtIso) return new Date(dtIso).toISOString(); // DD.MM.YYYY const dIso = fromGermanDate(trimmed); if (dIso) return new Date(dIso + 'T00:00:00').toISOString(); return ''; }; const datum_von = convertDate(rawVon ?? ''); const datum_bis = convertDate(rawBis ?? ''); if (!titel?.trim()) { return { titel: '', datum_von, datum_bis, ganztaegig, ort: null, beschreibung: null, valid: false, error: `Zeile ${lineNo}: Titel fehlt` }; } if (!datum_von) { return { titel: titel.trim(), datum_von, datum_bis, ganztaegig, ort: null, beschreibung: null, valid: false, error: `Zeile ${lineNo}: Datum Von ungültig` }; } return { titel: titel.trim(), datum_von, datum_bis: datum_bis || datum_von, ganztaegig, ort: ort?.trim() || null, beschreibung: beschreibung?.trim() || null, valid: true, }; } interface CsvImportDialogProps { open: boolean; onClose: () => void; onImported: () => void; } function CsvImportDialog({ open, onClose, onImported }: CsvImportDialogProps) { const [rows, setRows] = useState([]); const [importing, setImporting] = useState(false); const [result, setResult] = useState<{ created: number; errors: string[] } | null>(null); const notification = useNotification(); const fileRef = React.useRef(null); const handleFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { const text = ev.target?.result as string; const lines = text.split(/\r?\n/).filter((l) => l.trim()); // Skip header line const dataLines = lines[0]?.toLowerCase().includes('titel') ? lines.slice(1) : lines; const parsed = dataLines.map((line, i) => parseCsvRow(line, i + 2)); setRows(parsed); setResult(null); }; reader.readAsText(file, 'UTF-8'); }; const downloadExample = () => { const blob = new Blob(['\uFEFF' + CSV_EXAMPLE], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'kalender_import_beispiel.csv'; a.click(); URL.revokeObjectURL(url); }; const validRows = rows.filter((r) => r.valid); const handleImport = async () => { if (validRows.length === 0) return; setImporting(true); try { const events = validRows.map((r) => ({ titel: r.titel, datum_von: r.datum_von, datum_bis: r.datum_bis, ganztaegig: r.ganztaegig, ort: r.ort, beschreibung: r.beschreibung, zielgruppen: [], alle_gruppen: true, anmeldung_erforderlich: false, })); const res = await eventsApi.importEvents(events); setResult(res); if (res.created > 0) { notification.showSuccess(`${res.created} Veranstaltung${res.created !== 1 ? 'en' : ''} importiert`); onImported(); } } catch { notification.showError('Import fehlgeschlagen'); } finally { setImporting(false); } }; const handleClose = () => { setRows([]); setResult(null); if (fileRef.current) fileRef.current.value = ''; onClose(); }; return ( Kalender importieren (CSV) {rows.length > 0 && ( <> {validRows.length} gültige / {rows.length - validRows.length} fehlerhafte Zeilen {rows.some((r) => !r.valid) && ( {rows.filter((r) => !r.valid).map((r) => r.error).join('\n')} )} Status Titel Von Bis Ganztägig Ort {rows.map((row, i) => ( {row.titel} {row.datum_von ? new Date(row.datum_von).toLocaleDateString('de-DE') : '—'} {row.datum_bis ? new Date(row.datum_bis).toLocaleDateString('de-DE') : '—'} {row.ganztaegig ? 'Ja' : 'Nein'} {row.ort ?? '—'} ))}
)} {result && ( {result.created} Veranstaltung{result.created !== 1 ? 'en' : ''} importiert. {result.errors.length > 0 && ` ${result.errors.length} Fehler.`} )}
); } // ────────────────────────────────────────────────────────────────────────────── // Combined List View (training + events sorted by date) // ────────────────────────────────────────────────────────────────────────────── interface CombinedListViewProps { trainingEvents: UebungListItem[]; veranstaltungen: VeranstaltungListItem[]; selectedKategorie: string | 'all'; canWriteEvents: boolean; onTrainingClick: (id: string) => void; onEventEdit: (ev: VeranstaltungListItem) => void; onEventCancel: (id: string) => void; onEventDelete: (id: string) => void; } function CombinedListView({ trainingEvents, veranstaltungen, selectedKategorie, canWriteEvents, onTrainingClick, onEventEdit, onEventCancel, onEventDelete, }: CombinedListViewProps) { type ListEntry = | { kind: 'training'; item: UebungListItem } | { kind: 'event'; item: VeranstaltungListItem }; const combined = useMemo(() => { const entries: ListEntry[] = [ ...trainingEvents.map((t): ListEntry => ({ kind: 'training', item: t })), ...veranstaltungen .filter((e) => selectedKategorie === 'all' || e.kategorie_id === selectedKategorie) .map((e): ListEntry => ({ kind: 'event', item: e })), ]; return entries.sort((a, b) => { const da = a.kind === 'training' ? a.item.datum_von : a.item.datum_von; const db = b.kind === 'training' ? b.item.datum_von : b.item.datum_von; return da.localeCompare(db); }); }, [trainingEvents, veranstaltungen, selectedKategorie]); if (combined.length === 0) { return ( Keine Einträge in diesem Monat. ); } return ( {combined.map((entry, idx) => { const isTraining = entry.kind === 'training'; const item = entry.item; const color = isTraining ? TYP_DOT_COLOR[(item as UebungListItem).typ] : ((item as VeranstaltungListItem).kategorie_farbe ?? '#1976d2'); return ( {idx > 0 && } onTrainingClick(item.id) : undefined} > {/* Date badge */} {new Date(item.datum_von).getDate()}. {new Date(item.datum_von).getMonth() + 1}. {formatTime(item.datum_von)} {isTraining && (item as UebungListItem).pflichtveranstaltung && ( )} {item.titel} {item.abgesagt && ( )} } secondary={ {(item as { ort?: string | null }).ort && ( {(item as { ort?: string | null }).ort} )} {isTraining && ( )} } sx={{ my: 0 }} /> {!isTraining && canWriteEvents && !item.abgesagt && ( onEventEdit(item as VeranstaltungListItem)}> onEventCancel(item.id)}> )} {!isTraining && canWriteEvents && ( onEventDelete(item.id)} title="Endgültig löschen" > )}
); })} ); } // ────────────────────────────────────────────────────────────────────────────── // Veranstaltung Form Dialog // ────────────────────────────────────────────────────────────────────────────── interface VeranstaltungFormDialogProps { open: boolean; onClose: () => void; onSaved: () => void; editingEvent: VeranstaltungListItem | null; kategorien: VeranstaltungKategorie[]; groups: GroupInfo[]; } function VeranstaltungFormDialog({ open, onClose, onSaved, editingEvent, kategorien, groups, }: VeranstaltungFormDialogProps) { const notification = useNotification(); const [loading, setLoading] = useState(false); const [form, setForm] = useState({ ...EMPTY_VERANSTALTUNG_FORM }); const [wiederholungAktiv, setWiederholungAktiv] = useState(false); const [wiederholungTyp, setWiederholungTyp] = useState('wöchentlich'); const [wiederholungIntervall, setWiederholungIntervall] = useState(1); const [wiederholungBis, setWiederholungBis] = useState(''); const [wiederholungWochentag, setWiederholungWochentag] = useState(0); 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, }); // Populate recurrence fields if parent event has config (read-only display) if (editingEvent.wiederholung) { setWiederholungAktiv(true); setWiederholungTyp(editingEvent.wiederholung.typ); setWiederholungIntervall(editingEvent.wiederholung.intervall ?? 1); setWiederholungBis(editingEvent.wiederholung.bis ?? ''); setWiederholungWochentag(editingEvent.wiederholung.wochentag ?? 0); } else { setWiederholungAktiv(false); setWiederholungTyp('wöchentlich'); setWiederholungIntervall(1); setWiederholungBis(''); setWiederholungWochentag(0); } } else { const now = new Date(); now.setMinutes(0, 0, 0); const later = new Date(now); later.setHours(later.getHours() + 2); setForm({ ...EMPTY_VERANSTALTUNG_FORM, datum_von: now.toISOString(), datum_bis: later.toISOString() }); setWiederholungAktiv(false); setWiederholungTyp('wöchentlich'); setWiederholungIntervall(1); setWiederholungBis(''); setWiederholungWochentag(0); } }, [open, editingEvent]); // Auto-correct: end date should never be before start date useEffect(() => { const von = new Date(form.datum_von); const bis = new Date(form.datum_bis); if (!isNaN(von.getTime()) && !isNaN(bis.getTime()) && bis < von) { // Set datum_bis to datum_von (preserve time offset for non-ganztaegig) if (form.ganztaegig) { handleChange('datum_bis', von.toISOString()); } else { const adjusted = new Date(von); adjusted.setHours(adjusted.getHours() + 2); handleChange('datum_bis', adjusted.toISOString()); } } // Also auto-correct wiederholungBis if (wiederholungBis) { const vonDateOnly = form.datum_von.slice(0, 10); if (wiederholungBis < vonDateOnly) { setWiederholungBis(vonDateOnly); } } }, [form.datum_von]); // eslint-disable-line react-hooks/exhaustive-deps 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 handleGroupToggle = (groupId: string) => { setForm((prev) => { const updated = prev.zielgruppen.includes(groupId) ? prev.zielgruppen.filter((g) => g !== groupId) : [...prev.zielgruppen, groupId]; return { ...prev, zielgruppen: updated }; }); }; const handleSave = async () => { if (!form.titel.trim()) { notification.showError('Titel ist erforderlich'); return; } // Date validation const vonDate = new Date(form.datum_von); const bisDate = new Date(form.datum_bis); if (isNaN(vonDate.getTime())) { notification.showError('Ungültiges Datum Von'); return; } if (isNaN(bisDate.getTime())) { notification.showError('Ungültiges Datum Bis'); return; } if (bisDate < vonDate) { notification.showError('Datum Bis muss nach Datum Von liegen'); return; } if (wiederholungAktiv && !wiederholungBis) { notification.showError('Bitte Enddatum für Wiederholung angeben'); return; } if (wiederholungAktiv && wiederholungBis && isNaN(new Date(wiederholungBis).getTime())) { notification.showError('Ungültiges Datum für Wiederholung Bis'); return; } setLoading(true); try { const createPayload: CreateVeranstaltungInput = { ...form, wiederholung: ((!editingEvent || (editingEvent.wiederholung && !editingEvent.wiederholung_parent_id)) && wiederholungAktiv && wiederholungBis) ? { typ: wiederholungTyp, bis: wiederholungBis, intervall: wiederholungTyp === 'wöchentlich' ? wiederholungIntervall : undefined, wochentag: (wiederholungTyp === 'monatlich_erster_wochentag' || wiederholungTyp === 'monatlich_letzter_wochentag') ? wiederholungWochentag : undefined, } : null, }; if (editingEvent) { await eventsApi.updateEvent(editingEvent.id, createPayload); notification.showSuccess('Veranstaltung aktualisiert'); } else { await eventsApi.createEvent(createPayload); notification.showSuccess( wiederholungAktiv && wiederholungBis ? 'Veranstaltung und Wiederholungen erstellt' : 'Veranstaltung erstellt' ); } onSaved(); onClose(); } catch (e: unknown) { notification.showError(e instanceof Error ? e.message : 'Fehler beim Speichern'); } finally { setLoading(false); } }; return ( {editingEvent ? 'Veranstaltung bearbeiten' : 'Neue Veranstaltung'} handleChange('titel', e.target.value)} required fullWidth /> handleChange('beschreibung', e.target.value || null)} multiline rows={2} fullWidth /> Kategorie handleChange('ganztaegig', e.target.checked)} /> } label="Ganztägig" /> { const timeStr = form.datum_von?.substring(11, 16) || '00:00'; const d = new Date(`${isoDate}T${timeStr}:00`); if (isNaN(d.getTime())) return; handleChange('datum_von', d.toISOString()); }} sx={{ flex: 1 }} /> {!form.ganztaegig && ( { const dateStr = form.datum_von?.substring(0, 10) || new Date().toISOString().substring(0, 10); const d = new Date(`${dateStr}T${e.target.value}:00`); if (!isNaN(d.getTime())) handleChange('datum_von', d.toISOString()); }} InputLabelProps={{ shrink: true }} inputProps={{ step: 300 }} sx={{ width: 130 }} /> )} { const timeStr = form.datum_bis?.substring(11, 16) || '23:59'; const d = new Date(`${isoDate}T${timeStr}:00`); if (isNaN(d.getTime())) return; handleChange('datum_bis', d.toISOString()); }} sx={{ flex: 1 }} /> {!form.ganztaegig && ( { const dateStr = form.datum_bis?.substring(0, 10) || new Date().toISOString().substring(0, 10); const d = new Date(`${dateStr}T${e.target.value}:00`); if (!isNaN(d.getTime())) handleChange('datum_bis', d.toISOString()); }} InputLabelProps={{ shrink: true }} inputProps={{ step: 300 }} sx={{ width: 130 }} /> )} handleChange('ort', e.target.value || null)} fullWidth /> handleChange('alle_gruppen', e.target.checked)} /> } label="Für alle Mitglieder sichtbar" /> {!form.alle_gruppen && groups.length > 0 && ( Zielgruppen {groups.map((g) => ( handleGroupToggle(g.id)} size="small" /> } label={g.label} /> ))} )} handleChange('anmeldung_erforderlich', e.target.checked)} /> } label="Anmeldung erforderlich" /> {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 /> )} {/* Wiederholung */} {(!editingEvent || editingEvent.wiederholung || editingEvent.wiederholung_parent_id) && ( <> {editingEvent?.wiederholung_parent_id ? ( <> Dieser Termin ist Teil einer Serienveranstaltung. Änderungen betreffen nur diesen Einzeltermin. } label="Wiederkehrende Veranstaltung" /> ) : ( { setWiederholungAktiv(e.target.checked); if (e.target.checked && !wiederholungBis) { const bisDefault = new Date(form.datum_von); bisDefault.setMonth(bisDefault.getMonth() + 3); setWiederholungBis(bisDefault.toISOString().slice(0, 10)); } }} /> } label="Wiederkehrende Veranstaltung" /> )} {wiederholungAktiv && ( Wiederholung {wiederholungTyp === 'wöchentlich' && ( setWiederholungIntervall(Math.max(1, Math.min(52, parseInt(e.target.value) || 1)))} inputProps={{ min: 1, max: 52 }} fullWidth disabled={!!editingEvent?.wiederholung_parent_id} /> )} {(wiederholungTyp === 'monatlich_erster_wochentag' || wiederholungTyp === 'monatlich_letzter_wochentag') && ( Wochentag )} setWiederholungBis(iso)} fullWidth disabled={!!editingEvent?.wiederholung_parent_id} helperText="Letztes Datum für Wiederholungen" /> )} )} ); } // ────────────────────────────────────────────────────────────────────────────── // Settings Tab (Kategorien CRUD) // ────────────────────────────────────────────────────────────────────────────── interface SettingsTabProps { kategorien: VeranstaltungKategorie[]; onKategorienChange: (k: VeranstaltungKategorie[]) => void; } function SettingsTab({ kategorien, onKategorienChange }: SettingsTabProps) { const notification = useNotification(); const [editingKat, setEditingKat] = useState(null); const [newKatOpen, setNewKatOpen] = useState(false); const [newKatForm, setNewKatForm] = useState({ name: '', farbe: '#1976d2', beschreibung: '' }); const [saving, setSaving] = useState(false); const reload = async () => { const kat = await eventsApi.getKategorien(); onKategorienChange(kat); }; const handleCreate = async () => { if (!newKatForm.name.trim()) return; setSaving(true); try { await eventsApi.createKategorie({ name: newKatForm.name.trim(), farbe: newKatForm.farbe, beschreibung: newKatForm.beschreibung || undefined }); notification.showSuccess('Kategorie erstellt'); setNewKatOpen(false); setNewKatForm({ name: '', farbe: '#1976d2', beschreibung: '' }); await reload(); } catch { notification.showError('Fehler beim Erstellen'); } finally { setSaving(false); } }; const handleUpdate = async () => { if (!editingKat) return; setSaving(true); try { await eventsApi.updateKategorie(editingKat.id, { name: editingKat.name, farbe: editingKat.farbe, beschreibung: editingKat.beschreibung ?? undefined }); notification.showSuccess('Kategorie gespeichert'); setEditingKat(null); await reload(); } catch { notification.showError('Fehler beim Speichern'); } finally { setSaving(false); } }; const handleDelete = async (id: string) => { try { await eventsApi.deleteKategorie(id); notification.showSuccess('Kategorie gelöscht'); await reload(); } catch { notification.showError('Fehler beim Löschen'); } }; return ( Veranstaltungskategorien Farbe Name Beschreibung Aktionen {kategorien.map((k) => ( {k.name} {k.beschreibung ?? '—'} setEditingKat({ ...k })}> handleDelete(k.id)}> ))} {kategorien.length === 0 && ( Noch keine Kategorien vorhanden )}
{/* Edit dialog */} setEditingKat(null)} maxWidth="xs" fullWidth> Kategorie bearbeiten setEditingKat((k) => k ? { ...k, name: e.target.value } : k)} fullWidth required /> Farbe setEditingKat((k) => k ? { ...k, farbe: e.target.value } : k)} style={{ width: 40, height: 36, border: 'none', cursor: 'pointer', borderRadius: 4 }} /> {editingKat?.farbe} setEditingKat((k) => k ? { ...k, beschreibung: e.target.value || null } : k)} fullWidth multiline rows={2} /> {/* New category dialog */} setNewKatOpen(false)} maxWidth="xs" fullWidth> Neue Kategorie setNewKatForm((f) => ({ ...f, name: e.target.value }))} fullWidth required /> Farbe setNewKatForm((f) => ({ ...f, farbe: e.target.value }))} style={{ width: 40, height: 36, border: 'none', cursor: 'pointer', borderRadius: 4 }} /> {newKatForm.farbe} setNewKatForm((f) => ({ ...f, beschreibung: e.target.value }))} fullWidth multiline rows={2} />
); } // ────────────────────────────────────────────────────────────────────────────── // Main Kalender Page // ────────────────────────────────────────────────────────────────────────────── export default function Kalender() { const navigate = useNavigate(); const { hasPermission, isFeatureEnabled } = usePermissionContext(); const notification = useNotification(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const canWriteEvents = hasPermission('kalender:create'); // ── Tab / search params ─────────────────────────────────────────────────── const [searchParams, setSearchParams] = useSearchParams(); const activeTab = Number(searchParams.get('tab') ?? 0); const setActiveTab = (n: number) => setSearchParams({ tab: String(n) }); // ── Calendar state ───────────────────────────────────────────────────────── const today = new Date(); const [viewMonth, setViewMonth] = useState({ year: today.getFullYear(), month: today.getMonth(), }); const [viewMode, setViewMode] = useState<'calendar' | 'list' | 'day' | 'week'>('calendar'); const [currentDate, setCurrentDate] = useState(new Date()); const [selectedKategorie, setSelectedKategorie] = useState('all'); const [listFrom, setListFrom] = useState(() => fnsFormat(startOfMonth(today), 'yyyy-MM-dd')); const [listTo, setListTo] = useState(() => fnsFormat(endOfMonth(today), 'yyyy-MM-dd')); const [trainingEvents, setTrainingEvents] = useState([]); const [veranstaltungen, setVeranstaltungen] = useState([]); const [kategorien, setKategorien] = useState([]); const [groups, setGroups] = useState([]); const [calLoading, setCalLoading] = useState(false); const [calError, setCalError] = useState(null); // Popover state (day click) const [popoverAnchor, setPopoverAnchor] = useState(null); const [popoverDay, setPopoverDay] = useState(null); // Veranstaltung form / cancel dialogs const [veranstFormOpen, setVeranstFormOpen] = useState(false); const [veranstEditing, setVeranstEditing] = useState(null); const [cancelEventId, setCancelEventId] = useState(null); const [cancelEventGrund, setCancelEventGrund] = useState(''); const [cancelEventLoading, setCancelEventLoading] = useState(false); const [deleteEventId, setDeleteEventId] = useState(null); const [deleteEventLoading, setDeleteEventLoading] = useState(false); const [deleteMode, setDeleteMode] = useState<'single' | 'future' | 'all'>('all'); const [editScopeEvent, setEditScopeEvent] = useState(null); // iCal subscription const [icalEventOpen, setIcalEventOpen] = useState(false); const [icalEventUrl, setIcalEventUrl] = useState(''); const [csvImportOpen, setCsvImportOpen] = useState(false); // ── Data loading ───────────────────────────────────────────────────────────── const loadCalendarData = useCallback(async () => { setCalLoading(true); setCalError(null); try { let gridStart: Date; let gridEnd: Date; if (viewMode === 'day') { // Fetch the full month containing currentDate (plus padding) const monthStart = startOfMonth(currentDate); const dayOfWeek = (monthStart.getDay() + 6) % 7; gridStart = subDays(monthStart, dayOfWeek); gridEnd = addDays(gridStart, 41); } else if (viewMode === 'week') { // Fetch the month containing the current week const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 }); const weekEnd = endOfWeek(currentDate, { weekStartsOn: 1 }); const monthStart = startOfMonth(weekStart); const monthEnd = endOfMonth(weekEnd); gridStart = subDays(monthStart, 7); gridEnd = addDays(monthEnd, 7); } else if (viewMode === 'list') { // Fetch from listFrom to listTo, with padding gridStart = subDays(parseISO(listFrom), 1); gridEnd = addDays(parseISO(listTo), 1); } else { // Month view: 42-day grid based on viewMonth const firstDay = new Date(viewMonth.year, viewMonth.month, 1); const dayOfWeek = (firstDay.getDay() + 6) % 7; gridStart = new Date(firstDay); gridStart.setDate(gridStart.getDate() - dayOfWeek); gridEnd = new Date(gridStart); gridEnd.setDate(gridStart.getDate() + 41); } const [trainData, eventData] = await Promise.all([ trainingApi.getCalendarRange(gridStart, gridEnd), eventsApi.getCalendarRange(gridStart, gridEnd), ]); setTrainingEvents(trainData); setVeranstaltungen(eventData); } catch (e: unknown) { setCalError(e instanceof Error ? e.message : 'Fehler beim Laden'); } finally { setCalLoading(false); } }, [viewMonth, viewMode, currentDate, listFrom, listTo]); // Load kategorien + groups once useEffect(() => { Promise.all([eventsApi.getKategorien(), eventsApi.getGroups()]).then( ([kat, grp]) => { setKategorien(kat); setGroups(grp); } ); }, []); useEffect(() => { loadCalendarData(); }, [loadCalendarData]); // ── Calendar tab helpers ───────────────────────────────────────────────────── const handlePrev = () => { if (viewMode === 'day') { setCurrentDate((d) => subDays(d, 1)); } else if (viewMode === 'week') { setCurrentDate((d) => subWeeks(d, 1)); } else { 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 = () => { if (viewMode === 'day') { setCurrentDate((d) => addDays(d, 1)); } else if (viewMode === 'week') { setCurrentDate((d) => addWeeks(d, 1)); } else { 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() }); setCurrentDate(now); }; const handleDayClick = useCallback( (day: Date, anchor: Element) => { setPopoverDay(day); setPopoverAnchor(anchor); }, [] ); // Training + events for the popover day const trainingForDay = useMemo(() => { if (!popoverDay) return []; return trainingEvents.filter((t) => sameDay(new Date(t.datum_von), popoverDay) ); }, [trainingEvents, popoverDay]); const eventsForDay = useMemo(() => { if (!popoverDay) return []; const key = popoverDay.toISOString().slice(0, 10); return veranstaltungen.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; }); }, [veranstaltungen, popoverDay, selectedKategorie]); // Filtered lists for list view (filtered by date range) const trainingForMonth = useMemo( () => { const from = parseISO(listFrom); const to = parseISO(listTo); return trainingEvents.filter((t) => { const d = new Date(t.datum_von); return d >= startOfDay(from) && d <= new Date(to.getFullYear(), to.getMonth(), to.getDate(), 23, 59, 59); }); }, [trainingEvents, listFrom, listTo] ); const eventsForMonth = useMemo( () => { const from = parseISO(listFrom); const to = parseISO(listTo); return veranstaltungen.filter((ev) => { const d = new Date(ev.datum_von); return d >= startOfDay(from) && d <= new Date(to.getFullYear(), to.getMonth(), to.getDate(), 23, 59, 59); }); }, [veranstaltungen, listFrom, listTo] ); // Events for the selected day (day view) const trainingForCurrentDay = useMemo( () => trainingEvents.filter((t) => sameDay(new Date(t.datum_von), currentDate)), [trainingEvents, currentDate] ); const eventsForCurrentDay = useMemo( () => veranstaltungen.filter((ev) => { const start = startOfDay(new Date(ev.datum_von)); const end = startOfDay(new Date(ev.datum_bis)); const cur = startOfDay(currentDate); return cur >= start && cur <= end; }), [veranstaltungen, currentDate] ); // Events for the selected week (week view) const currentWeekStartCal = useMemo( () => startOfWeek(currentDate, { weekStartsOn: 1 }), [currentDate] ); const currentWeekEndCal = useMemo( () => endOfWeek(currentDate, { weekStartsOn: 1 }), [currentDate] ); const weekDaysCal = useMemo( () => eachDayOfInterval({ start: currentWeekStartCal, end: currentWeekEndCal }), [currentWeekStartCal, currentWeekEndCal] ); // ── Veranstaltung cancel ───────────────────────────────────────────────────── const handleCancelEvent = async () => { if (!cancelEventId || cancelEventGrund.trim().length < 5) return; setCancelEventLoading(true); try { await eventsApi.cancelEvent(cancelEventId, cancelEventGrund.trim()); notification.showSuccess('Veranstaltung wurde abgesagt'); setCancelEventId(null); setCancelEventGrund(''); loadCalendarData(); } catch (e: unknown) { notification.showError((e as any)?.message || 'Fehler beim Absagen'); } finally { setCancelEventLoading(false); } }; const handleDeleteEvent = async () => { if (!deleteEventId) return; setDeleteEventLoading(true); try { await eventsApi.deleteEvent(deleteEventId, deleteMode); notification.showSuccess('Veranstaltung wurde endgültig gelöscht'); setDeleteEventId(null); loadCalendarData(); } catch (e: unknown) { notification.showError((e as any)?.message || 'Fehler beim Löschen'); } finally { setDeleteEventLoading(false); } }; const handleOpenDeleteDialog = useCallback((id: string) => { setDeleteMode('all'); setDeleteEventId(id); }, []); const handleEventEdit = useCallback(async (ev: VeranstaltungListItem) => { if (ev.wiederholung_parent_id) { setEditScopeEvent(ev); return; } setVeranstEditing(ev); setVeranstFormOpen(true); }, []); const handleIcalEventOpen = async () => { try { const { subscribeUrl } = await eventsApi.getCalendarToken(); setIcalEventUrl(subscribeUrl); setIcalEventOpen(true); } catch (e: unknown) { const msg = e instanceof Error ? e.message : 'Fehler beim Laden des Tokens'; notification.showError(msg); } }; // ── Render ─────────────────────────────────────────────────────────────────── if (!isFeatureEnabled('kalender')) { return ; } return ( {/* Page header */} Kalender {canWriteEvents ? ( setActiveTab(v)} sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}> } iconPosition="start" label="Einstellungen" value={1} /> ) : null} {activeTab === 0 && ( <> {/* ── Calendar ───────────────────────────────────────────── */} {/* Controls row */} {/* View toggle */} {/* PDF Export — available in all views */} { const start = new Date(viewMonth.year, viewMonth.month, 1); const end = new Date(viewMonth.year, viewMonth.month + 1, 0, 23, 59, 59); const trainToExport = trainingEvents.filter((t) => { const d = new Date(t.datum_von); return d >= start && d <= end; }); const eventsToExport = veranstaltungen.filter((e) => { const d = new Date(e.datum_von); return d >= start && d <= end; }); generatePdf(viewMonth.year, viewMonth.month, trainToExport, eventsToExport); }} > {/* CSV Import */} {canWriteEvents && ( setCsvImportOpen(true)} > )} {/* iCal subscribe */} {/* Category filter — between controls and navigation */} {kategorien.length > 0 && ( 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, }} variant={selectedKategorie === k.id ? 'filled' : 'outlined'} /> ))} )} {/* Navigation */} {viewMode === 'day' ? formatDateLong(currentDate) : viewMode === 'week' ? `KW ${fnsFormat(currentWeekStartCal, 'w')} — ${fnsFormat(currentWeekStartCal, 'dd.MM.')} – ${fnsFormat(currentWeekEndCal, 'dd.MM.yyyy')}` : `${MONTH_LABELS[viewMonth.month]} ${viewMonth.year}`} {calError && ( setCalError(null)}> {calError} )} {/* Calendar / List / Day / Week body */} {calLoading ? ( ) : viewMode === 'day' ? ( /* ── Day View ── */ {trainingForCurrentDay.length === 0 && eventsForCurrentDay.length === 0 ? ( Keine Einträge an diesem Tag. ) : ( {trainingForCurrentDay.length > 0 && ( Dienste )} {trainingForCurrentDay.map((t) => ( navigate(`/training/${t.id}`)} > {t.pflichtveranstaltung && } {t.titel} } secondary={`${formatTime(t.datum_von)} – ${formatTime(t.datum_bis)} Uhr · ${t.typ}`} /> ))} {trainingForCurrentDay.length > 0 && eventsForCurrentDay.length > 0 && } {eventsForCurrentDay.length > 0 && ( Veranstaltungen )} {eventsForCurrentDay .filter((ev) => selectedKategorie === 'all' || ev.kategorie_id === selectedKategorie) .map((ev) => ( {ev.titel} {ev.abgesagt && } } secondary={ <> {ev.ganztaegig ? 'Ganztägig' : `${formatTime(ev.datum_von)} – ${formatTime(ev.datum_bis)} Uhr`} {ev.ort && ` · ${ev.ort}`} } /> ))} )} ) : viewMode === 'week' ? ( /* ── Week View ── */ {weekDaysCal.map((day) => { const isToday = sameDay(day, new Date()); const dayTraining = trainingEvents.filter((t) => sameDay(new Date(t.datum_von), day)); const dayEvents = veranstaltungen.filter((ev) => { if (selectedKategorie !== 'all' && ev.kategorie_id !== selectedKategorie) return false; const start = startOfDay(new Date(ev.datum_von)); const end = startOfDay(new Date(ev.datum_bis)); const cur = startOfDay(day); return cur >= start && cur <= end; }); return ( {fnsFormat(day, 'EEE dd.MM.', { locale: de })} {dayTraining.map((t) => ( navigate(`/training/${t.id}`)} sx={{ fontSize: '0.6rem', height: 18, mb: '2px', width: '100%', justifyContent: 'flex-start', bgcolor: t.abgesagt ? 'action.disabledBackground' : TYP_DOT_COLOR[t.typ], color: t.abgesagt ? 'text.disabled' : 'white', textDecoration: t.abgesagt ? 'line-through' : 'none', cursor: 'pointer', }} /> ))} {dayEvents.map((ev) => ( ))} ); })} ) : viewMode === 'calendar' ? ( ) : ( <> {/* Date range inputs for list view */} setListFrom(iso)} sx={{ width: 170 }} /> setListTo(iso)} sx={{ width: 170 }} /> navigate(`/training/${id}`)} onEventEdit={handleEventEdit} onEventCancel={(id) => { setCancelEventId(id); setCancelEventGrund(''); }} onEventDelete={handleOpenDeleteDialog} /> )} {/* FAB: Create Veranstaltung */} {canWriteEvents && ( { setVeranstEditing(null); setVeranstFormOpen(true); }} > )} {/* Day Popover */} setPopoverAnchor(null)} onTrainingClick={(id) => navigate(`/training/${id}`)} onEventEdit={handleEventEdit} onEventDelete={handleOpenDeleteDialog} /> {/* Veranstaltung Form Dialog */} { setVeranstFormOpen(false); setVeranstEditing(null); }} onSaved={loadCalendarData} editingEvent={veranstEditing} kategorien={kategorien} groups={groups} /> {/* Veranstaltung Cancel Dialog */} setCancelEventId(null)} maxWidth="xs" fullWidth > Veranstaltung absagen Bitte gib einen Grund für die Absage an (mind. 5 Zeichen). setCancelEventGrund(e.target.value)} autoFocus /> {/* Endgültig löschen Dialog */} {(() => { const deleteEvent = veranstaltungen.find(e => e.id === deleteEventId) ?? null; const isRecurring = Boolean(deleteEvent?.wiederholung || deleteEvent?.wiederholung_parent_id); return ( setDeleteEventId(null)} maxWidth="xs" fullWidth > Veranstaltung endgültig löschen? Diese Aktion kann nicht rückgängig gemacht werden. {isRecurring && ( setDeleteMode(e.target.value as 'single' | 'future' | 'all')} sx={{ mt: 2 }} > } label="Nur diesen Termin" /> } label="Diesen und alle folgenden Termine" /> } label="Alle Termine der Serie" /> )} ); })()} {/* Edit scope dialog for recurring event instances */} setEditScopeEvent(null)} maxWidth="xs" fullWidth > Wiederkehrenden Termin bearbeiten Welche Termine möchtest du bearbeiten? {/* iCal Event subscription dialog */} setIcalEventOpen(false)} maxWidth="sm" fullWidth> Kalender abonnieren Abonniere den Dienste- & Veranstaltungskalender in deiner Kalenderanwendung. Kopiere die URL und füge sie als neuen Kalender (per URL) in Apple Kalender, Google Kalender oder Outlook ein. { navigator.clipboard.writeText(icalEventUrl); notification.showSuccess('URL kopiert!'); }} > ), }} /> )} {activeTab === 1 && canWriteEvents && ( )} {/* CSV Import Dialog */} setCsvImportOpen(false)} onImported={() => { setCsvImportOpen(false); loadCalendarData(); }} /> ); }