import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Alert, Box, Button, ButtonGroup, Checkbox, Chip, CircularProgress, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Divider, Fab, FormControl, FormControlLabel, FormGroup, IconButton, InputLabel, List, ListItem, ListItemText, MenuItem, Paper, Popover, 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, DirectionsCar as CarIcon, Edit as EditIcon, Event as EventIcon, FileDownload as FileDownloadIcon, FileUpload as FileUploadIcon, HelpOutline as UnknownIcon, IosShare, PictureAsPdf as PdfIcon, Star as StarIcon, Today as TodayIcon, Tune, ViewList as ListViewIcon, Warning, } from '@mui/icons-material'; import { useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import { toGermanDate, toGermanDateTime, fromGermanDate, fromGermanDateTime } from '../utils/dateInput'; import { useAuth } from '../contexts/AuthContext'; import { useNotification } from '../contexts/NotificationContext'; import { trainingApi } from '../services/training'; import { eventsApi } from '../services/events'; import { bookingApi, fetchVehicles } from '../services/bookings'; import type { UebungListItem, UebungTyp, TeilnahmeStatus, } from '../types/training.types'; import type { VeranstaltungListItem, VeranstaltungKategorie, GroupInfo, CreateVeranstaltungInput, WiederholungConfig, } from '../types/events.types'; import type { FahrzeugBuchungListItem, Fahrzeug, CreateBuchungInput, BuchungsArt, } from '../types/booking.types'; import { BUCHUNGS_ART_LABELS, BUCHUNGS_ART_COLORS } from '../types/booking.types'; import { format as fnsFormat, startOfWeek, endOfWeek, addWeeks, subWeeks, eachDayOfInterval, isToday as fnsIsToday, parseISO, isSameDay, } from 'date-fns'; import { de } from 'date-fns/locale'; // ────────────────────────────────────────────────────────────────────────────── // Constants // ────────────────────────────────────────────────────────────────────────────── const WRITE_GROUPS_EVENTS = ['dashboard_admin', 'dashboard_moderator']; const WRITE_GROUPS_BOOKINGS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_moderator']; 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, }; const EMPTY_BOOKING_FORM: CreateBuchungInput = { fahrzeugId: '', titel: '', beschreibung: '', beginn: '', ende: '', buchungsArt: 'intern', kontaktPerson: '', kontaktTelefon: '', }; // ────────────────────────────────────────────────────────────────────────────── // 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()}`; } function fromDatetimeLocal(value: string): string { if (!value) return new Date().toISOString(); const dtIso = fromGermanDateTime(value); if (dtIso) return new Date(dtIso).toISOString(); const dIso = fromGermanDate(value); if (dIso) return new Date(dIso).toISOString(); return new Date(value).toISOString(); } // ────────────────────────────────────────────────────────────────────────────── // 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(); }} > )} ))} )} ); } // ────────────────────────────────────────────────────────────────────────────── // PDF Export helper // ────────────────────────────────────────────────────────────────────────────── 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]; // Header bar doc.setFillColor(183, 28, 28); // fire-red doc.rect(0, 0, 297, 18, 'F'); doc.setTextColor(255, 255, 255); doc.setFontSize(14); doc.setFont('helvetica', 'bold'); doc.text(`Kalender — ${monthLabel} ${year}`, 10, 12); doc.setFontSize(9); doc.setFont('helvetica', 'normal'); doc.text('Feuerwehr Rems', 250, 12); // 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: 22, 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 }, }, }); 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, , beschreibung] = parts; 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, }); 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]); 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; } setLoading(true); try { const createPayload: CreateVeranstaltungInput = { ...form, wiederholung: (!editingEvent && wiederholungAktiv && wiederholungBis) ? { typ: wiederholungTyp, bis: fromGermanDate(wiederholungBis) || 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, form); 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 raw = e.target.value; const iso = form.ganztaegig ? fromDatetimeLocal(raw ? `${fromGermanDate(raw)} 00:00` : '') : fromDatetimeLocal(raw); handleChange('datum_von', iso); }} InputLabelProps={{ shrink: true }} fullWidth /> { const raw = e.target.value; const iso = form.ganztaegig ? fromDatetimeLocal(raw ? `${fromGermanDate(raw)} 23:59` : '') : fromDatetimeLocal(raw); handleChange('datum_bis', iso); }} InputLabelProps={{ shrink: true }} fullWidth /> 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 (only for new events) */} {!editingEvent && ( <> setWiederholungAktiv(e.target.checked)} /> } 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 /> )} {(wiederholungTyp === 'monatlich_erster_wochentag' || wiederholungTyp === 'monatlich_letzter_wochentag') && ( Wochentag )} setWiederholungBis(e.target.value)} InputLabelProps={{ shrink: true }} fullWidth helperText="Letztes Datum für Wiederholungen" /> )} )} ); } // ────────────────────────────────────────────────────────────────────────────── // Main Kalender Page // ────────────────────────────────────────────────────────────────────────────── export default function Kalender() { const navigate = useNavigate(); const { user } = useAuth(); const notification = useNotification(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const canWriteEvents = user?.groups?.some((g) => WRITE_GROUPS_EVENTS.includes(g)) ?? false; const canWriteBookings = user?.groups?.some((g) => WRITE_GROUPS_BOOKINGS.includes(g)) ?? false; const canCreateBookings = !!user; // ── Tab ───────────────────────────────────────────────────────────────────── const [activeTab, setActiveTab] = useState(0); // ── Calendar tab state ─────────────────────────────────────────────────────── 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'); 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); // ── Bookings tab state ─────────────────────────────────────────────────────── const [currentWeekStart, setCurrentWeekStart] = useState(() => startOfWeek(new Date(), { weekStartsOn: 1 }) ); const weekEnd = endOfWeek(currentWeekStart, { weekStartsOn: 1 }); const weekDays = eachDayOfInterval({ start: currentWeekStart, end: weekEnd }); const weekLabel = `KW ${fnsFormat(currentWeekStart, 'w')} · ${fnsFormat(currentWeekStart, 'dd.MM.')} – ${fnsFormat(weekEnd, 'dd.MM.yyyy')}`; const [vehicles, setVehicles] = useState([]); const [bookings, setBookings] = useState([]); const [bookingsLoading, setBookingsLoading] = useState(false); const [bookingsError, setBookingsError] = useState(null); // Booking detail popover const [detailAnchor, setDetailAnchor] = useState(null); const [detailBooking, setDetailBooking] = useState(null); // Booking form dialog const [bookingDialogOpen, setBookingDialogOpen] = useState(false); const [editingBooking, setEditingBooking] = useState(null); const [bookingForm, setBookingForm] = useState({ ...EMPTY_BOOKING_FORM }); const [bookingDialogLoading, setBookingDialogLoading] = useState(false); const [bookingDialogError, setBookingDialogError] = useState(null); const [availability, setAvailability] = useState(null); // Cancel booking const [cancelBookingId, setCancelBookingId] = useState(null); const [cancelBookingGrund, setCancelBookingGrund] = useState(''); const [cancelBookingLoading, setCancelBookingLoading] = useState(false); // iCal subscription const [icalEventOpen, setIcalEventOpen] = useState(false); const [icalEventUrl, setIcalEventUrl] = useState(''); const [icalBookingOpen, setIcalBookingOpen] = useState(false); const [csvImportOpen, setCsvImportOpen] = useState(false); const [icalBookingUrl, setIcalBookingUrl] = useState(''); // ── Data loading ───────────────────────────────────────────────────────────── const loadCalendarData = useCallback(async () => { setCalLoading(true); setCalError(null); try { 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 [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]); // Load kategorien + groups once useEffect(() => { Promise.all([eventsApi.getKategorien(), eventsApi.getGroups()]).then( ([kat, grp]) => { setKategorien(kat); setGroups(grp); } ); }, []); useEffect(() => { loadCalendarData(); }, [loadCalendarData]); const loadBookingsData = useCallback(async () => { setBookingsLoading(true); setBookingsError(null); try { const end = endOfWeek(currentWeekStart, { weekStartsOn: 1 }); const [vehiclesData, bookingsData] = await Promise.all([ fetchVehicles(), bookingApi.getCalendarRange(currentWeekStart, end), ]); setVehicles(vehiclesData); setBookings(bookingsData); } catch (e: unknown) { setBookingsError(e instanceof Error ? e.message : 'Fehler beim Laden'); } finally { setBookingsLoading(false); } }, [currentWeekStart]); useEffect(() => { loadBookingsData(); }, [loadBookingsData]); // ── Calendar tab helpers ───────────────────────────────────────────────────── 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 = () => { setViewMonth({ year: today.getFullYear(), month: today.getMonth() }); }; 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 (current month only) const trainingForMonth = useMemo( () => trainingEvents.filter((t) => { const d = new Date(t.datum_von); return d.getMonth() === viewMonth.month && d.getFullYear() === viewMonth.year; }), [trainingEvents, viewMonth] ); const eventsForMonth = useMemo( () => veranstaltungen.filter((ev) => { const d = new Date(ev.datum_von); return d.getMonth() === viewMonth.month && d.getFullYear() === viewMonth.year; }), [veranstaltungen, viewMonth] ); // ── 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); 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); } }; // ── Booking helpers ────────────────────────────────────────────────────────── const getBookingsForCell = (vehicleId: string, day: Date): FahrzeugBuchungListItem[] => bookings.filter((b) => { if (b.fahrzeug_id !== vehicleId || b.abgesagt) return false; const start = parseISO(b.beginn); const end = parseISO(b.ende); return isSameDay(start, day) || isSameDay(end, day) || (start < day && end > day); }); const openBookingCreate = () => { setEditingBooking(null); setBookingForm({ ...EMPTY_BOOKING_FORM }); setBookingDialogError(null); setAvailability(null); setBookingDialogOpen(true); }; const handleCellClick = (vehicleId: string, day: Date) => { if (!canCreateBookings) return; setEditingBooking(null); setBookingForm({ ...EMPTY_BOOKING_FORM, fahrzeugId: vehicleId, beginn: toGermanDateTime(fnsFormat(day, "yyyy-MM-dd'T'08:00")), ende: toGermanDateTime(fnsFormat(day, "yyyy-MM-dd'T'17:00")), }); setBookingDialogError(null); setAvailability(null); setBookingDialogOpen(true); }; const handleOpenBookingEdit = () => { if (!detailBooking) return; setEditingBooking(detailBooking); setBookingForm({ fahrzeugId: detailBooking.fahrzeug_id, titel: detailBooking.titel, beschreibung: '', beginn: toGermanDateTime(fnsFormat(parseISO(detailBooking.beginn as unknown as string), "yyyy-MM-dd'T'HH:mm")), ende: toGermanDateTime(fnsFormat(parseISO(detailBooking.ende as unknown as string), "yyyy-MM-dd'T'HH:mm")), buchungsArt: detailBooking.buchungs_art, kontaktPerson: '', kontaktTelefon: '', }); setBookingDialogError(null); setAvailability(null); setBookingDialogOpen(true); setDetailAnchor(null); setDetailBooking(null); }; // Availability check useEffect(() => { if (!bookingForm.fahrzeugId || !bookingForm.beginn || !bookingForm.ende) { setAvailability(null); return; } let cancelled = false; const beginnIso = fromGermanDateTime(bookingForm.beginn) || bookingForm.beginn; const endeIso = fromGermanDateTime(bookingForm.ende) || bookingForm.ende; bookingApi .checkAvailability( bookingForm.fahrzeugId, new Date(beginnIso), new Date(endeIso) ) .then(({ available }) => { if (!cancelled) setAvailability(available); }) .catch(() => { if (!cancelled) setAvailability(null); }); return () => { cancelled = true; }; }, [bookingForm.fahrzeugId, bookingForm.beginn, bookingForm.ende]); const handleBookingSave = async () => { setBookingDialogLoading(true); setBookingDialogError(null); try { const payload: CreateBuchungInput = { ...bookingForm, beginn: (() => { const iso = fromGermanDateTime(bookingForm.beginn); return iso ? new Date(iso).toISOString() : new Date(bookingForm.beginn).toISOString(); })(), ende: (() => { const iso = fromGermanDateTime(bookingForm.ende); return iso ? new Date(iso).toISOString() : new Date(bookingForm.ende).toISOString(); })(), }; if (editingBooking) { await bookingApi.update(editingBooking.id, payload); notification.showSuccess('Buchung aktualisiert'); } else { await bookingApi.create(payload); notification.showSuccess('Buchung erstellt'); } setBookingDialogOpen(false); loadBookingsData(); } catch (e: unknown) { const axiosError = e as { response?: { status?: number }; message?: string }; if (axiosError?.response?.status === 409) { setBookingDialogError('Fahrzeug ist im gewählten Zeitraum bereits gebucht'); } else { setBookingDialogError(axiosError?.message || 'Fehler beim Speichern'); } } finally { setBookingDialogLoading(false); } }; const handleBookingCancel = async () => { if (!cancelBookingId) return; setCancelBookingLoading(true); try { await bookingApi.cancel(cancelBookingId, cancelBookingGrund); notification.showSuccess('Buchung storniert'); setCancelBookingId(null); setDetailAnchor(null); setDetailBooking(null); loadBookingsData(); } catch (e: unknown) { notification.showError(e instanceof Error ? e.message : 'Fehler beim Stornieren'); } finally { setCancelBookingLoading(false); } }; 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); } }; const handleIcalBookingOpen = async () => { try { const { subscribeUrl } = await bookingApi.getCalendarToken(); setIcalBookingUrl(subscribeUrl); setIcalBookingOpen(true); } catch (e: unknown) { const msg = e instanceof Error ? e.message : 'Fehler beim Laden des Tokens'; notification.showError(msg); } }; // ── Render ─────────────────────────────────────────────────────────────────── return ( {/* Page header */} Kalender {/* Tabs */} setActiveTab(v)} sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }} > } iconPosition="start" label="Dienste & Veranstaltungen" /> } iconPosition="start" label="Fahrzeugbuchungen" /> {/* ── TAB 0: Calendar ───────────────────────────────────────────── */} {activeTab === 0 && ( {/* Controls row */} {/* View toggle */} {/* Category filter */} {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'} /> ))} )} {/* Kategorien verwalten */} {canWriteEvents && ( navigate('/veranstaltungen/kategorien')} > )} {/* PDF Export — only in list view */} {viewMode === 'list' && ( generatePdf( viewMonth.year, viewMonth.month, trainingForMonth, eventsForMonth, )} > )} {/* CSV Import */} {canWriteEvents && ( setCsvImportOpen(true)} > )} {/* iCal subscribe */} {/* Month navigation */} {MONTH_LABELS[viewMonth.month]} {viewMonth.year} {calError && ( setCalError(null)}> {calError} )} {/* Calendar / List body */} {calLoading ? ( ) : viewMode === 'calendar' ? ( ) : ( navigate(`/training/${id}`)} onEventEdit={(ev) => { setVeranstEditing(ev); setVeranstFormOpen(true); }} onEventCancel={(id) => { setCancelEventId(id); setCancelEventGrund(''); }} onEventDelete={(id) => setDeleteEventId(id)} /> )} {/* FAB: Create Veranstaltung */} {canWriteEvents && ( { setVeranstEditing(null); setVeranstFormOpen(true); }} > )} {/* Day Popover */} setPopoverAnchor(null)} onTrainingClick={(id) => navigate(`/training/${id}`)} onEventEdit={(ev) => { setVeranstEditing(ev); setVeranstFormOpen(true); }} onEventDelete={(id) => setDeleteEventId(id)} /> {/* 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 */} setDeleteEventId(null)} maxWidth="xs" fullWidth > Veranstaltung endgültig löschen? Diese Veranstaltung wird endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden. {/* 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!'); }} > ), }} /> )} {/* ── TAB 1: Fahrzeugbuchungen ──────────────────────────────────── */} {activeTab === 1 && ( {/* Week navigation */} setCurrentWeekStart((d) => subWeeks(d, 1))} > {weekLabel} setCurrentWeekStart((d) => addWeeks(d, 1))} > {canCreateBookings && ( )} {/* iCal subscribe */} {bookingsLoading && ( )} {!bookingsLoading && bookingsError && ( {bookingsError} )} {!bookingsLoading && !bookingsError && ( <> Fahrzeug {weekDays.map((day) => ( {fnsFormat(day, 'EEE', { locale: de })} {fnsFormat(day, 'dd.MM.')} ))} {vehicles.map((vehicle) => ( {vehicle.bezeichnung} {vehicle.amtliches_kennzeichen && ( {vehicle.amtliches_kennzeichen} )} {weekDays.map((day) => { const cellBookings = getBookingsForCell(vehicle.id, day); const isFree = cellBookings.length === 0; return ( isFree ? handleCellClick(vehicle.id, day) : undefined } sx={{ bgcolor: isFree ? 'success.50' : undefined, cursor: isFree && canCreateBookings ? 'pointer' : 'default', '&:hover': isFree && canCreateBookings ? { bgcolor: 'success.100' } : {}, p: 0.5, verticalAlign: 'top', }} > {cellBookings.map((b) => ( 12 ? b.titel.slice(0, 12) + '…' : b.titel } size="small" onClick={(e) => { e.stopPropagation(); setDetailBooking(b); setDetailAnchor(e.currentTarget); }} sx={{ bgcolor: BUCHUNGS_ART_COLORS[b.buchungs_art], color: 'white', fontSize: '0.65rem', height: 20, mb: 0.25, display: 'flex', width: '100%', cursor: 'pointer', }} /> ))} ); })} ))} {vehicles.length === 0 && ( Keine aktiven Fahrzeuge )}
{/* Legend */} Frei {(Object.entries(BUCHUNGS_ART_LABELS) as [BuchungsArt, string][]).map( ([art, label]) => ( {label} ) )} )} {/* FAB */} {canCreateBookings && ( )} {/* Booking detail popover */} { setDetailAnchor(null); setDetailBooking(null); }} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} transformOrigin={{ vertical: 'top', horizontal: 'center' }} > {detailBooking && ( {detailBooking.titel} {fnsFormat(parseISO(detailBooking.beginn), 'dd.MM.yyyy HH:mm')} {' – '} {fnsFormat(parseISO(detailBooking.ende), 'dd.MM.yyyy HH:mm')} {detailBooking.gebucht_von_name && ( Von: {detailBooking.gebucht_von_name} )} {canWriteBookings && ( )} )} {/* Booking create/edit dialog */} setBookingDialogOpen(false)} maxWidth="sm" fullWidth > {editingBooking ? 'Buchung bearbeiten' : 'Neue Buchung'} {bookingDialogError && ( {bookingDialogError} )} Fahrzeug setBookingForm((f) => ({ ...f, titel: e.target.value })) } /> setBookingForm((f) => ({ ...f, beginn: e.target.value })) } InputLabelProps={{ shrink: true }} /> setBookingForm((f) => ({ ...f, ende: e.target.value })) } InputLabelProps={{ shrink: true }} /> {availability !== null && ( : } label={availability ? 'Fahrzeug verfügbar' : 'Konflikt: bereits gebucht'} color={availability ? 'success' : 'error'} size="small" sx={{ alignSelf: 'flex-start' }} /> )} Buchungsart {bookingForm.buchungsArt === 'extern' && ( <> setBookingForm((f) => ({ ...f, kontaktPerson: e.target.value })) } /> setBookingForm((f) => ({ ...f, kontaktTelefon: e.target.value })) } /> )} {/* Cancel booking dialog */} setCancelBookingId(null)} maxWidth="xs" fullWidth > Buchung stornieren setCancelBookingGrund(e.target.value)} sx={{ mt: 1 }} helperText={`${cancelBookingGrund.length}/1000 (min. 5 Zeichen)`} inputProps={{ maxLength: 1000 }} /> {/* iCal Booking subscription dialog */} setIcalBookingOpen(false)} maxWidth="sm" fullWidth> Kalender abonnieren Abonniere den Fahrzeugbuchungskalender 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(icalBookingUrl); notification.showSuccess('URL kopiert!'); }} > ), }} /> )} {/* CSV Import Dialog */} setCsvImportOpen(false)} onImported={() => { setCsvImportOpen(false); loadCalendarData(); }} />
); }