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