Files
dashboard/frontend/src/pages/Veranstaltungen.tsx
Matthias Hochmeister 507111e8e8 update
2026-03-26 12:12:18 +01:00

1602 lines
54 KiB
TypeScript
Raw Blame History

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