1602 lines
54 KiB
TypeScript
1602 lines
54 KiB
TypeScript
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'} "{c.titel}" ({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>
|
||
);
|
||
}
|