Files
dashboard/frontend/src/pages/Kalender.tsx
Matthias Hochmeister d3561c1109 new features, bookstack
2026-03-03 21:30:38 +01:00

2700 lines
101 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
Alert,
Box,
Button,
ButtonGroup,
Checkbox,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
Fab,
FormControl,
FormControlLabel,
FormGroup,
IconButton,
InputLabel,
List,
ListItem,
ListItemText,
MenuItem,
Paper,
Popover,
Select,
Skeleton,
Stack,
Switch,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tabs,
TextField,
Tooltip,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import {
Add,
Cancel as CancelIcon,
CalendarViewMonth as CalendarIcon,
CheckCircle as CheckIcon,
ChevronLeft,
ChevronRight,
ContentCopy as CopyIcon,
DeleteForever as DeleteForeverIcon,
DirectionsCar as CarIcon,
Edit as EditIcon,
Event as EventIcon,
FileDownload as FileDownloadIcon,
FileUpload as FileUploadIcon,
HelpOutline as UnknownIcon,
IosShare,
PictureAsPdf as PdfIcon,
Star as StarIcon,
Today as TodayIcon,
Tune,
ViewList as ListViewIcon,
Warning,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { toGermanDate, toGermanDateTime, fromGermanDate, fromGermanDateTime } from '../utils/dateInput';
import { useAuth } from '../contexts/AuthContext';
import { useNotification } from '../contexts/NotificationContext';
import { trainingApi } from '../services/training';
import { eventsApi } from '../services/events';
import { bookingApi, fetchVehicles } from '../services/bookings';
import type {
UebungListItem,
UebungTyp,
TeilnahmeStatus,
} from '../types/training.types';
import type {
VeranstaltungListItem,
VeranstaltungKategorie,
GroupInfo,
CreateVeranstaltungInput,
WiederholungConfig,
} from '../types/events.types';
import type {
FahrzeugBuchungListItem,
Fahrzeug,
CreateBuchungInput,
BuchungsArt,
} from '../types/booking.types';
import { BUCHUNGS_ART_LABELS, BUCHUNGS_ART_COLORS } from '../types/booking.types';
import {
format as fnsFormat,
startOfWeek,
endOfWeek,
addWeeks,
subWeeks,
eachDayOfInterval,
isToday as fnsIsToday,
parseISO,
isSameDay,
} from 'date-fns';
import { de } from 'date-fns/locale';
// ──────────────────────────────────────────────────────────────────────────────
// Constants
// ──────────────────────────────────────────────────────────────────────────────
const WRITE_GROUPS_EVENTS = ['dashboard_admin', 'dashboard_moderator'];
const WRITE_GROUPS_BOOKINGS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_moderator'];
const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
const MONTH_LABELS = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
];
const TYP_DOT_COLOR: Record<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,
};
const EMPTY_BOOKING_FORM: CreateBuchungInput = {
fahrzeugId: '',
titel: '',
beschreibung: '',
beginn: '',
ende: '',
buchungsArt: 'intern',
kontaktPerson: '',
kontaktTelefon: '',
};
// ──────────────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────────────
function startOfDay(d: Date): Date {
const c = new Date(d);
c.setHours(0, 0, 0, 0);
return c;
}
function sameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
function buildMonthGrid(year: number, month: number): Date[] {
const firstDay = new Date(year, month, 1);
const dayOfWeek = (firstDay.getDay() + 6) % 7;
const start = new Date(firstDay);
start.setDate(start.getDate() - dayOfWeek);
const cells: Date[] = [];
for (let i = 0; i < 42; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
cells.push(d);
}
return cells;
}
function formatTime(isoString: string): string {
const d = new Date(isoString);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function formatDateLong(d: Date): string {
const days = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
return `${days[d.getDay()]}, ${d.getDate()}. ${MONTH_LABELS[d.getMonth()]} ${d.getFullYear()}`;
}
function fromDatetimeLocal(value: string): string {
if (!value) return new Date().toISOString();
const dtIso = fromGermanDateTime(value);
if (dtIso) return new Date(dtIso).toISOString();
const dIso = fromGermanDate(value);
if (dIso) return new Date(dIso).toISOString();
return new Date(value).toISOString();
}
// ──────────────────────────────────────────────────────────────────────────────
// Types for unified calendar
// ──────────────────────────────────────────────────────────────────────────────
interface TrainingDayEvent {
kind: 'training';
id: string;
color: string;
titel: string;
abgesagt: boolean;
pflicht: boolean;
datum_von: string;
}
interface VeranstaltungDayEvent {
kind: 'event';
id: string;
color: string;
titel: string;
abgesagt: boolean;
datum_von: string;
}
type CalDayEvent = TrainingDayEvent | VeranstaltungDayEvent;
// ──────────────────────────────────────────────────────────────────────────────
// RSVP dot
// ──────────────────────────────────────────────────────────────────────────────
function RsvpDot({ status }: { status: TeilnahmeStatus | undefined }) {
if (!status || status === 'unbekannt')
return <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>
);
}
// ──────────────────────────────────────────────────────────────────────────────
// PDF Export helper
// ──────────────────────────────────────────────────────────────────────────────
async function generatePdf(
year: number,
month: number,
trainingEvents: UebungListItem[],
veranstaltungen: VeranstaltungListItem[],
) {
// Dynamically import jsPDF to avoid bundle bloat if not needed
const { jsPDF } = await import('jspdf');
const autoTable = (await import('jspdf-autotable')).default;
const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
const monthLabel = MONTH_LABELS[month];
// Header bar
doc.setFillColor(183, 28, 28); // fire-red
doc.rect(0, 0, 297, 18, 'F');
doc.setTextColor(255, 255, 255);
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text(`Kalender — ${monthLabel} ${year}`, 10, 12);
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.text('Feuerwehr Rems', 250, 12);
// Build combined list (same logic as CombinedListView)
type ListEntry =
| { kind: 'training'; item: UebungListItem }
| { kind: 'event'; item: VeranstaltungListItem };
const combined: ListEntry[] = [
...trainingEvents.map((t): ListEntry => ({ kind: 'training', item: t })),
...veranstaltungen.map((e): ListEntry => ({ kind: 'event', item: e })),
].sort((a, b) => a.item.datum_von.localeCompare(b.item.datum_von));
const formatDateCell = (iso: string) => {
const d = new Date(iso);
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
};
const formatTimeCell = (iso: string) => {
const d = new Date(iso);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
const rows = combined.map((entry) => {
const item = entry.item;
return [
formatDateCell(item.datum_von),
formatTimeCell(item.datum_von),
item.titel,
entry.kind === 'training'
? (item as UebungListItem).typ
: ((item as VeranstaltungListItem).kategorie_name ?? 'Veranstaltung'),
(item as any).ort ?? '',
];
});
autoTable(doc, {
head: [['Datum', 'Uhrzeit', 'Titel', 'Kategorie/Typ', 'Ort']],
body: rows,
startY: 22,
headStyles: { fillColor: [183, 28, 28], textColor: 255, fontStyle: 'bold' },
alternateRowStyles: { fillColor: [250, 235, 235] },
margin: { left: 10, right: 10 },
styles: { fontSize: 9, cellPadding: 2 },
columnStyles: {
0: { cellWidth: 25 },
1: { cellWidth: 18 },
2: { cellWidth: 90 },
3: { cellWidth: 40 },
4: { cellWidth: 60 },
},
});
const filename = `kalender_${year}_${String(month + 1).padStart(2, '0')}.pdf`;
doc.save(filename);
}
// ──────────────────────────────────────────────────────────────────────────────
// CSV Import Dialog
// ──────────────────────────────────────────────────────────────────────────────
const CSV_EXAMPLE = [
'Titel;Datum Von;Datum Bis;Ganztaegig;Ort;Kategorie;Beschreibung',
'Übung Atemschutz;15.03.2026 19:00;15.03.2026 21:00;Nein;Feuerwehrhaus;Übung;Atemschutzübung für alle',
'Tag der offenen Tür;20.04.2026;20.04.2026;Ja;Feuerwehrhaus;Veranstaltung;',
].join('\n');
interface CsvRow {
titel: string;
datum_von: string;
datum_bis: string;
ganztaegig: boolean;
ort: string | null;
beschreibung: string | null;
valid: boolean;
error?: string;
}
function parseCsvRow(line: string, lineNo: number): CsvRow {
const parts = line.split(';');
if (parts.length < 4) {
return { titel: '', datum_von: '', datum_bis: '', ganztaegig: false, ort: null, beschreibung: null, valid: false, error: `Zeile ${lineNo}: Zu wenige Spalten` };
}
const [titel, rawVon, rawBis, rawGanztaegig, ort, , beschreibung] = parts;
const ganztaegig = rawGanztaegig?.trim().toLowerCase() === 'ja';
const convertDate = (raw: string): string => {
const trimmed = raw.trim();
// DD.MM.YYYY HH:MM
const dtIso = fromGermanDateTime(trimmed);
if (dtIso) return new Date(dtIso).toISOString();
// DD.MM.YYYY
const dIso = fromGermanDate(trimmed);
if (dIso) return new Date(dIso + 'T00:00:00').toISOString();
return '';
};
const datum_von = convertDate(rawVon ?? '');
const datum_bis = convertDate(rawBis ?? '');
if (!titel?.trim()) {
return { titel: '', datum_von, datum_bis, ganztaegig, ort: null, beschreibung: null, valid: false, error: `Zeile ${lineNo}: Titel fehlt` };
}
if (!datum_von) {
return { titel: titel.trim(), datum_von, datum_bis, ganztaegig, ort: null, beschreibung: null, valid: false, error: `Zeile ${lineNo}: Datum Von ungültig` };
}
return {
titel: titel.trim(),
datum_von,
datum_bis: datum_bis || datum_von,
ganztaegig,
ort: ort?.trim() || null,
beschreibung: beschreibung?.trim() || null,
valid: true,
};
}
interface CsvImportDialogProps {
open: boolean;
onClose: () => void;
onImported: () => void;
}
function CsvImportDialog({ open, onClose, onImported }: CsvImportDialogProps) {
const [rows, setRows] = useState<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,
});
setWiederholungAktiv(false);
setWiederholungTyp('wöchentlich');
setWiederholungIntervall(1);
setWiederholungBis('');
setWiederholungWochentag(0);
} else {
const now = new Date();
now.setMinutes(0, 0, 0);
const later = new Date(now);
later.setHours(later.getHours() + 2);
setForm({ ...EMPTY_VERANSTALTUNG_FORM, datum_von: now.toISOString(), datum_bis: later.toISOString() });
setWiederholungAktiv(false);
setWiederholungTyp('wöchentlich');
setWiederholungIntervall(1);
setWiederholungBis('');
setWiederholungWochentag(0);
}
}, [open, editingEvent]);
const handleChange = (field: keyof CreateVeranstaltungInput, value: unknown) => {
if (field === 'kategorie_id' && !editingEvent) {
// Auto-fill zielgruppen / alle_gruppen from the selected category (only for new events)
const kat = kategorien.find((k) => k.id === value);
if (kat) {
setForm((prev) => ({
...prev,
kategorie_id: value as string | null,
alle_gruppen: kat.alle_gruppen,
zielgruppen: kat.alle_gruppen ? [] : kat.zielgruppen,
}));
return;
}
}
setForm((prev) => ({ ...prev, [field]: value }));
};
const handleGroupToggle = (groupId: string) => {
setForm((prev) => {
const updated = prev.zielgruppen.includes(groupId)
? prev.zielgruppen.filter((g) => g !== groupId)
: [...prev.zielgruppen, groupId];
return { ...prev, zielgruppen: updated };
});
};
const handleSave = async () => {
if (!form.titel.trim()) {
notification.showError('Titel ist erforderlich');
return;
}
setLoading(true);
try {
const createPayload: CreateVeranstaltungInput = {
...form,
wiederholung: (!editingEvent && wiederholungAktiv && wiederholungBis)
? {
typ: wiederholungTyp,
bis: fromGermanDate(wiederholungBis) || wiederholungBis,
intervall: wiederholungTyp === 'wöchentlich' ? wiederholungIntervall : undefined,
wochentag: (wiederholungTyp === 'monatlich_erster_wochentag' || wiederholungTyp === 'monatlich_letzter_wochentag')
? wiederholungWochentag
: undefined,
}
: null,
};
if (editingEvent) {
await eventsApi.updateEvent(editingEvent.id, form);
notification.showSuccess('Veranstaltung aktualisiert');
} else {
await eventsApi.createEvent(createPayload);
notification.showSuccess(
wiederholungAktiv && wiederholungBis
? 'Veranstaltung und Wiederholungen erstellt'
: 'Veranstaltung erstellt'
);
}
onSaved();
onClose();
} catch (e: unknown) {
notification.showError(e instanceof Error ? e.message : 'Fehler beim Speichern');
} finally {
setLoading(false);
}
};
return (
<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"
/>
<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)} 00:00` : '')
: fromDatetimeLocal(raw);
handleChange('datum_von', iso);
}}
InputLabelProps={{ shrink: true }}
fullWidth
/>
<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)} 23:59` : '')
: fromDatetimeLocal(raw);
handleChange('datum_bis', iso);
}}
InputLabelProps={{ shrink: true }}
fullWidth
/>
<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 (only for new events) */}
{!editingEvent && (
<>
<Divider />
<FormControlLabel
control={
<Switch
checked={wiederholungAktiv}
onChange={(e) => setWiederholungAktiv(e.target.checked)}
/>
}
label="Wiederkehrende Veranstaltung"
/>
{wiederholungAktiv && (
<Stack spacing={2}>
<FormControl fullWidth size="small">
<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
/>
)}
{(wiederholungTyp === 'monatlich_erster_wochentag' || wiederholungTyp === 'monatlich_letzter_wochentag') && (
<FormControl fullWidth size="small">
<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>
)}
<TextField
label="Wiederholungen bis"
size="small"
placeholder="TT.MM.JJJJ"
value={wiederholungBis}
onChange={(e) => setWiederholungBis(e.target.value)}
InputLabelProps={{ shrink: true }}
fullWidth
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>
);
}
// ──────────────────────────────────────────────────────────────────────────────
// Main Kalender Page
// ──────────────────────────────────────────────────────────────────────────────
export default function Kalender() {
const navigate = useNavigate();
const { user } = useAuth();
const notification = useNotification();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const canWriteEvents =
user?.groups?.some((g) => WRITE_GROUPS_EVENTS.includes(g)) ?? false;
const canWriteBookings =
user?.groups?.some((g) => WRITE_GROUPS_BOOKINGS.includes(g)) ?? false;
const canCreateBookings = !!user;
// ── Tab ─────────────────────────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState(0);
// ── Calendar tab state ───────────────────────────────────────────────────────
const today = new Date();
const [viewMonth, setViewMonth] = useState({
year: today.getFullYear(),
month: today.getMonth(),
});
const [viewMode, setViewMode] = useState<'calendar' | 'list'>('calendar');
const [selectedKategorie, setSelectedKategorie] = useState<string | 'all'>('all');
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);
// ── Bookings tab state ───────────────────────────────────────────────────────
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(() =>
startOfWeek(new Date(), { weekStartsOn: 1 })
);
const weekEnd = endOfWeek(currentWeekStart, { weekStartsOn: 1 });
const weekDays = eachDayOfInterval({ start: currentWeekStart, end: weekEnd });
const weekLabel = `KW ${fnsFormat(currentWeekStart, 'w')} · ${fnsFormat(currentWeekStart, 'dd.MM.')} ${fnsFormat(weekEnd, 'dd.MM.yyyy')}`;
const [vehicles, setVehicles] = useState<Fahrzeug[]>([]);
const [bookings, setBookings] = useState<FahrzeugBuchungListItem[]>([]);
const [bookingsLoading, setBookingsLoading] = useState(false);
const [bookingsError, setBookingsError] = useState<string | null>(null);
// Booking detail popover
const [detailAnchor, setDetailAnchor] = useState<HTMLElement | null>(null);
const [detailBooking, setDetailBooking] = useState<FahrzeugBuchungListItem | null>(null);
// Booking form dialog
const [bookingDialogOpen, setBookingDialogOpen] = useState(false);
const [editingBooking, setEditingBooking] = useState<FahrzeugBuchungListItem | null>(null);
const [bookingForm, setBookingForm] = useState<CreateBuchungInput>({ ...EMPTY_BOOKING_FORM });
const [bookingDialogLoading, setBookingDialogLoading] = useState(false);
const [bookingDialogError, setBookingDialogError] = useState<string | null>(null);
const [availability, setAvailability] = useState<boolean | null>(null);
// Cancel booking
const [cancelBookingId, setCancelBookingId] = useState<string | null>(null);
const [cancelBookingGrund, setCancelBookingGrund] = useState('');
const [cancelBookingLoading, setCancelBookingLoading] = useState(false);
// iCal subscription
const [icalEventOpen, setIcalEventOpen] = useState(false);
const [icalEventUrl, setIcalEventUrl] = useState('');
const [icalBookingOpen, setIcalBookingOpen] = useState(false);
const [csvImportOpen, setCsvImportOpen] = useState(false);
const [icalBookingUrl, setIcalBookingUrl] = useState('');
// ── Data loading ─────────────────────────────────────────────────────────────
const loadCalendarData = useCallback(async () => {
setCalLoading(true);
setCalError(null);
try {
const firstDay = new Date(viewMonth.year, viewMonth.month, 1);
const dayOfWeek = (firstDay.getDay() + 6) % 7;
const gridStart = new Date(firstDay);
gridStart.setDate(gridStart.getDate() - dayOfWeek);
const gridEnd = new Date(gridStart);
gridEnd.setDate(gridStart.getDate() + 41);
const [trainData, eventData] = await Promise.all([
trainingApi.getCalendarRange(gridStart, gridEnd),
eventsApi.getCalendarRange(gridStart, gridEnd),
]);
setTrainingEvents(trainData);
setVeranstaltungen(eventData);
} catch (e: unknown) {
setCalError(e instanceof Error ? e.message : 'Fehler beim Laden');
} finally {
setCalLoading(false);
}
}, [viewMonth]);
// Load kategorien + groups once
useEffect(() => {
Promise.all([eventsApi.getKategorien(), eventsApi.getGroups()]).then(
([kat, grp]) => {
setKategorien(kat);
setGroups(grp);
}
);
}, []);
useEffect(() => {
loadCalendarData();
}, [loadCalendarData]);
const loadBookingsData = useCallback(async () => {
setBookingsLoading(true);
setBookingsError(null);
try {
const end = endOfWeek(currentWeekStart, { weekStartsOn: 1 });
const [vehiclesData, bookingsData] = await Promise.all([
fetchVehicles(),
bookingApi.getCalendarRange(currentWeekStart, end),
]);
setVehicles(vehiclesData);
setBookings(bookingsData);
} catch (e: unknown) {
setBookingsError(e instanceof Error ? e.message : 'Fehler beim Laden');
} finally {
setBookingsLoading(false);
}
}, [currentWeekStart]);
useEffect(() => {
loadBookingsData();
}, [loadBookingsData]);
// ── Calendar tab helpers ─────────────────────────────────────────────────────
const handlePrev = () => {
setViewMonth((prev) => {
const m = prev.month === 0 ? 11 : prev.month - 1;
const y = prev.month === 0 ? prev.year - 1 : prev.year;
return { year: y, month: m };
});
};
const handleNext = () => {
setViewMonth((prev) => {
const m = prev.month === 11 ? 0 : prev.month + 1;
const y = prev.month === 11 ? prev.year + 1 : prev.year;
return { year: y, month: m };
});
};
const handleToday = () => {
setViewMonth({ year: today.getFullYear(), month: today.getMonth() });
};
const handleDayClick = useCallback(
(day: Date, anchor: Element) => {
setPopoverDay(day);
setPopoverAnchor(anchor);
},
[]
);
// Training + events for the popover day
const trainingForDay = useMemo(() => {
if (!popoverDay) return [];
return trainingEvents.filter((t) =>
sameDay(new Date(t.datum_von), popoverDay)
);
}, [trainingEvents, popoverDay]);
const eventsForDay = useMemo(() => {
if (!popoverDay) return [];
const key = popoverDay.toISOString().slice(0, 10);
return veranstaltungen.filter((ev) => {
if (selectedKategorie !== 'all' && ev.kategorie_id !== selectedKategorie) return false;
const start = startOfDay(new Date(ev.datum_von)).toISOString().slice(0, 10);
const end = startOfDay(new Date(ev.datum_bis)).toISOString().slice(0, 10);
return key >= start && key <= end;
});
}, [veranstaltungen, popoverDay, selectedKategorie]);
// Filtered lists for list view (current month only)
const trainingForMonth = useMemo(
() =>
trainingEvents.filter((t) => {
const d = new Date(t.datum_von);
return d.getMonth() === viewMonth.month && d.getFullYear() === viewMonth.year;
}),
[trainingEvents, viewMonth]
);
const eventsForMonth = useMemo(
() =>
veranstaltungen.filter((ev) => {
const d = new Date(ev.datum_von);
return d.getMonth() === viewMonth.month && d.getFullYear() === viewMonth.year;
}),
[veranstaltungen, viewMonth]
);
// ── Veranstaltung cancel ─────────────────────────────────────────────────────
const handleCancelEvent = async () => {
if (!cancelEventId || cancelEventGrund.trim().length < 5) return;
setCancelEventLoading(true);
try {
await eventsApi.cancelEvent(cancelEventId, cancelEventGrund.trim());
notification.showSuccess('Veranstaltung wurde abgesagt');
setCancelEventId(null);
setCancelEventGrund('');
loadCalendarData();
} catch (e: unknown) {
notification.showError((e as any)?.message || 'Fehler beim Absagen');
} finally {
setCancelEventLoading(false);
}
};
const handleDeleteEvent = async () => {
if (!deleteEventId) return;
setDeleteEventLoading(true);
try {
await eventsApi.deleteEvent(deleteEventId);
notification.showSuccess('Veranstaltung wurde endgültig gelöscht');
setDeleteEventId(null);
loadCalendarData();
} catch (e: unknown) {
notification.showError((e as any)?.message || 'Fehler beim Löschen');
} finally {
setDeleteEventLoading(false);
}
};
// ── Booking helpers ──────────────────────────────────────────────────────────
const getBookingsForCell = (vehicleId: string, day: Date): FahrzeugBuchungListItem[] =>
bookings.filter((b) => {
if (b.fahrzeug_id !== vehicleId || b.abgesagt) return false;
const start = parseISO(b.beginn);
const end = parseISO(b.ende);
return isSameDay(start, day) || isSameDay(end, day) || (start < day && end > day);
});
const openBookingCreate = () => {
setEditingBooking(null);
setBookingForm({ ...EMPTY_BOOKING_FORM });
setBookingDialogError(null);
setAvailability(null);
setBookingDialogOpen(true);
};
const handleCellClick = (vehicleId: string, day: Date) => {
if (!canCreateBookings) return;
setEditingBooking(null);
setBookingForm({
...EMPTY_BOOKING_FORM,
fahrzeugId: vehicleId,
beginn: toGermanDateTime(fnsFormat(day, "yyyy-MM-dd'T'08:00")),
ende: toGermanDateTime(fnsFormat(day, "yyyy-MM-dd'T'17:00")),
});
setBookingDialogError(null);
setAvailability(null);
setBookingDialogOpen(true);
};
const handleOpenBookingEdit = () => {
if (!detailBooking) return;
setEditingBooking(detailBooking);
setBookingForm({
fahrzeugId: detailBooking.fahrzeug_id,
titel: detailBooking.titel,
beschreibung: '',
beginn: toGermanDateTime(fnsFormat(parseISO(detailBooking.beginn as unknown as string), "yyyy-MM-dd'T'HH:mm")),
ende: toGermanDateTime(fnsFormat(parseISO(detailBooking.ende as unknown as string), "yyyy-MM-dd'T'HH:mm")),
buchungsArt: detailBooking.buchungs_art,
kontaktPerson: '',
kontaktTelefon: '',
});
setBookingDialogError(null);
setAvailability(null);
setBookingDialogOpen(true);
setDetailAnchor(null);
setDetailBooking(null);
};
// Availability check
useEffect(() => {
if (!bookingForm.fahrzeugId || !bookingForm.beginn || !bookingForm.ende) {
setAvailability(null);
return;
}
let cancelled = false;
const beginnIso = fromGermanDateTime(bookingForm.beginn) || bookingForm.beginn;
const endeIso = fromGermanDateTime(bookingForm.ende) || bookingForm.ende;
bookingApi
.checkAvailability(
bookingForm.fahrzeugId,
new Date(beginnIso),
new Date(endeIso)
)
.then(({ available }) => {
if (!cancelled) setAvailability(available);
})
.catch(() => {
if (!cancelled) setAvailability(null);
});
return () => { cancelled = true; };
}, [bookingForm.fahrzeugId, bookingForm.beginn, bookingForm.ende]);
const handleBookingSave = async () => {
setBookingDialogLoading(true);
setBookingDialogError(null);
try {
const payload: CreateBuchungInput = {
...bookingForm,
beginn: (() => { const iso = fromGermanDateTime(bookingForm.beginn); return iso ? new Date(iso).toISOString() : new Date(bookingForm.beginn).toISOString(); })(),
ende: (() => { const iso = fromGermanDateTime(bookingForm.ende); return iso ? new Date(iso).toISOString() : new Date(bookingForm.ende).toISOString(); })(),
};
if (editingBooking) {
await bookingApi.update(editingBooking.id, payload);
notification.showSuccess('Buchung aktualisiert');
} else {
await bookingApi.create(payload);
notification.showSuccess('Buchung erstellt');
}
setBookingDialogOpen(false);
loadBookingsData();
} catch (e: unknown) {
const axiosError = e as { response?: { status?: number }; message?: string };
if (axiosError?.response?.status === 409) {
setBookingDialogError('Fahrzeug ist im gewählten Zeitraum bereits gebucht');
} else {
setBookingDialogError(axiosError?.message || 'Fehler beim Speichern');
}
} finally {
setBookingDialogLoading(false);
}
};
const handleBookingCancel = async () => {
if (!cancelBookingId) return;
setCancelBookingLoading(true);
try {
await bookingApi.cancel(cancelBookingId, cancelBookingGrund);
notification.showSuccess('Buchung storniert');
setCancelBookingId(null);
setDetailAnchor(null);
setDetailBooking(null);
loadBookingsData();
} catch (e: unknown) {
notification.showError(e instanceof Error ? e.message : 'Fehler beim Stornieren');
} finally {
setCancelBookingLoading(false);
}
};
const handleIcalEventOpen = async () => {
try {
const { subscribeUrl } = await eventsApi.getCalendarToken();
setIcalEventUrl(subscribeUrl);
setIcalEventOpen(true);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Fehler beim Laden des Tokens';
notification.showError(msg);
}
};
const handleIcalBookingOpen = async () => {
try {
const { subscribeUrl } = await bookingApi.getCalendarToken();
setIcalBookingUrl(subscribeUrl);
setIcalBookingOpen(true);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Fehler beim Laden des Tokens';
notification.showError(msg);
}
};
// ── Render ───────────────────────────────────────────────────────────────────
return (
<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>
{/* Tabs */}
<Tabs
value={activeTab}
onChange={(_, v) => setActiveTab(v)}
sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}
>
<Tab icon={<EventIcon />} iconPosition="start" label="Dienste & Veranstaltungen" />
<Tab icon={<CarIcon />} iconPosition="start" label="Fahrzeugbuchungen" />
</Tabs>
{/* ── TAB 0: Calendar ───────────────────────────────────────────── */}
{activeTab === 0 && (
<Box>
{/* Controls row */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: 1,
mb: 2,
}}
>
{/* 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>
{/* Category filter */}
{kategorien.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
<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>
)}
{/* Kategorien verwalten */}
{canWriteEvents && (
<Tooltip title="Kategorien verwalten">
<IconButton
size="small"
onClick={() => navigate('/veranstaltungen/kategorien')}
>
<Tune fontSize="small" />
</IconButton>
</Tooltip>
)}
{/* PDF Export — only in list view */}
{viewMode === 'list' && (
<Tooltip title="Als PDF exportieren">
<IconButton
size="small"
onClick={() => generatePdf(
viewMonth.year,
viewMonth.month,
trainingForMonth,
eventsForMonth,
)}
>
<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>
{/* Month 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 }}
>
{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 body */}
{calLoading ? (
<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}
trainingEvents={trainingEvents}
veranstaltungen={veranstaltungen}
selectedKategorie={selectedKategorie}
onDayClick={handleDayClick}
/>
</Paper>
) : (
<Paper elevation={1} sx={{ px: 1 }}>
<CombinedListView
trainingEvents={trainingForMonth}
veranstaltungen={eventsForMonth}
selectedKategorie={selectedKategorie}
canWriteEvents={canWriteEvents}
onTrainingClick={(id) => navigate(`/training/${id}`)}
onEventEdit={(ev) => {
setVeranstEditing(ev);
setVeranstFormOpen(true);
}}
onEventCancel={(id) => {
setCancelEventId(id);
setCancelEventGrund('');
}}
onEventDelete={(id) => setDeleteEventId(id)}
/>
</Paper>
)}
{/* FAB: Create Veranstaltung */}
{canWriteEvents && (
<Fab
color="primary"
sx={{ position: 'fixed', bottom: 32, right: 32 }}
onClick={() => {
setVeranstEditing(null);
setVeranstFormOpen(true);
}}
>
<Add />
</Fab>
)}
{/* Day Popover */}
<DayPopover
anchorEl={popoverAnchor}
day={popoverDay}
trainingForDay={trainingForDay}
eventsForDay={eventsForDay}
canWriteEvents={canWriteEvents}
onClose={() => setPopoverAnchor(null)}
onTrainingClick={(id) => navigate(`/training/${id}`)}
onEventEdit={(ev) => {
setVeranstEditing(ev);
setVeranstFormOpen(true);
}}
onEventDelete={(id) => setDeleteEventId(id)}
/>
{/* 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 */}
<Dialog
open={Boolean(deleteEventId)}
onClose={() => setDeleteEventId(null)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Veranstaltung endgültig löschen?</DialogTitle>
<DialogContent>
<DialogContentText>
Diese Veranstaltung wird endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.
</DialogContentText>
</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>
{/* 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>
</DialogActions>
</Dialog>
</Box>
)}
{/* ── TAB 1: Fahrzeugbuchungen ──────────────────────────────────── */}
{activeTab === 1 && (
<Box>
{/* Week navigation */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 2,
flexWrap: 'wrap',
gap: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton
onClick={() => setCurrentWeekStart((d) => subWeeks(d, 1))}
>
<ChevronLeft />
</IconButton>
<Typography
variant="h6"
sx={{ minWidth: 260, textAlign: 'center', userSelect: 'none' }}
>
{weekLabel}
</Typography>
<IconButton
onClick={() => setCurrentWeekStart((d) => addWeeks(d, 1))}
>
<ChevronRight />
</IconButton>
<Button
size="small"
startIcon={<TodayIcon />}
onClick={() =>
setCurrentWeekStart(startOfWeek(new Date(), { weekStartsOn: 1 }))
}
>
Heute
</Button>
</Box>
{canCreateBookings && (
<Button
variant="contained"
startIcon={<Add />}
size="small"
onClick={openBookingCreate}
>
Neue Buchung
</Button>
)}
{/* iCal subscribe */}
<Button
startIcon={<IosShare />}
onClick={handleIcalBookingOpen}
variant="outlined"
size="small"
>
Kalender
</Button>
</Box>
{bookingsLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
)}
{!bookingsLoading && bookingsError && (
<Alert severity="error" sx={{ mb: 2 }}>
{bookingsError}
</Alert>
)}
{!bookingsLoading && !bookingsError && (
<>
<TableContainer component={Paper} elevation={1}>
<Table size="small" sx={{ tableLayout: 'fixed' }}>
<TableHead>
<TableRow sx={{ bgcolor: 'grey.100' }}>
<TableCell sx={{ width: 160, fontWeight: 700 }}>
Fahrzeug
</TableCell>
{weekDays.map((day) => (
<TableCell
key={day.toISOString()}
align="center"
sx={{
fontWeight: fnsIsToday(day) ? 700 : 400,
color: fnsIsToday(day) ? 'primary.main' : 'text.primary',
bgcolor: fnsIsToday(day) ? 'primary.50' : undefined,
}}
>
<Typography variant="caption" display="block">
{fnsFormat(day, 'EEE', { locale: de })}
</Typography>
<Typography variant="body2" fontWeight="inherit">
{fnsFormat(day, 'dd.MM.')}
</Typography>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{vehicles.map((vehicle) => (
<TableRow key={vehicle.id} hover>
<TableCell>
<Typography variant="body2" fontWeight={600}>
{vehicle.bezeichnung}
</Typography>
{vehicle.amtliches_kennzeichen && (
<Typography variant="caption" color="text.secondary">
{vehicle.amtliches_kennzeichen}
</Typography>
)}
</TableCell>
{weekDays.map((day) => {
const cellBookings = getBookingsForCell(vehicle.id, day);
const isFree = cellBookings.length === 0;
return (
<TableCell
key={day.toISOString()}
onClick={() =>
isFree ? handleCellClick(vehicle.id, day) : undefined
}
sx={{
bgcolor: isFree ? 'success.50' : undefined,
cursor: isFree && canCreateBookings ? 'pointer' : 'default',
'&:hover':
isFree && canCreateBookings
? { bgcolor: 'success.100' }
: {},
p: 0.5,
verticalAlign: 'top',
}}
>
{cellBookings.map((b) => (
<Tooltip
key={b.id}
title={`${b.titel} (${BUCHUNGS_ART_LABELS[b.buchungs_art]})`}
>
<Chip
label={
b.titel.length > 12
? b.titel.slice(0, 12) + '…'
: b.titel
}
size="small"
onClick={(e) => {
e.stopPropagation();
setDetailBooking(b);
setDetailAnchor(e.currentTarget);
}}
sx={{
bgcolor: BUCHUNGS_ART_COLORS[b.buchungs_art],
color: 'white',
fontSize: '0.65rem',
height: 20,
mb: 0.25,
display: 'flex',
width: '100%',
cursor: 'pointer',
}}
/>
</Tooltip>
))}
</TableCell>
);
})}
</TableRow>
))}
{vehicles.length === 0 && (
<TableRow>
<TableCell colSpan={8} align="center">
<Typography
variant="body2"
color="text.secondary"
sx={{ py: 2 }}
>
Keine aktiven Fahrzeuge
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{/* Legend */}
<Box sx={{ display: 'flex', gap: 2, mt: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box
sx={{
width: 16, height: 16,
bgcolor: 'success.50',
border: '1px solid', borderColor: 'success.300', borderRadius: 0.5,
}}
/>
<Typography variant="caption">Frei</Typography>
</Box>
{(Object.entries(BUCHUNGS_ART_LABELS) as [BuchungsArt, string][]).map(
([art, label]) => (
<Box key={art} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box
sx={{
width: 16, height: 16,
bgcolor: BUCHUNGS_ART_COLORS[art], borderRadius: 0.5,
}}
/>
<Typography variant="caption">{label}</Typography>
</Box>
)
)}
</Box>
</>
)}
{/* FAB */}
{canCreateBookings && (
<Fab
color="primary"
sx={{ position: 'fixed', bottom: 32, right: 32 }}
onClick={openBookingCreate}
>
<Add />
</Fab>
)}
{/* Booking detail popover */}
<Popover
open={Boolean(detailAnchor)}
anchorEl={detailAnchor}
onClose={() => { setDetailAnchor(null); setDetailBooking(null); }}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
>
{detailBooking && (
<Box sx={{ p: 2, maxWidth: 280 }}>
<Typography variant="subtitle2" fontWeight={700}>
{detailBooking.titel}
</Typography>
<Chip
label={BUCHUNGS_ART_LABELS[detailBooking.buchungs_art]}
size="small"
sx={{
bgcolor: BUCHUNGS_ART_COLORS[detailBooking.buchungs_art],
color: 'white', mb: 1, mt: 0.5,
}}
/>
<Typography variant="body2">
{fnsFormat(parseISO(detailBooking.beginn), 'dd.MM.yyyy HH:mm')}
{' '}
{fnsFormat(parseISO(detailBooking.ende), 'dd.MM.yyyy HH:mm')}
</Typography>
{detailBooking.gebucht_von_name && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
Von: {detailBooking.gebucht_von_name}
</Typography>
)}
{canWriteBookings && (
<Box sx={{ mt: 1.5, display: 'flex', gap: 1 }}>
<Button size="small" startIcon={<EditIcon />} onClick={handleOpenBookingEdit}>
Bearbeiten
</Button>
<Button
size="small"
color="error"
startIcon={<CancelIcon />}
onClick={() => {
if (!detailBooking) return;
setCancelBookingId(detailBooking.id);
setCancelBookingGrund('');
setDetailAnchor(null);
setDetailBooking(null);
}}
>
Stornieren
</Button>
</Box>
)}
</Box>
)}
</Popover>
{/* Booking create/edit dialog */}
<Dialog
open={bookingDialogOpen}
onClose={() => setBookingDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>
{editingBooking ? 'Buchung bearbeiten' : 'Neue Buchung'}
</DialogTitle>
<DialogContent>
{bookingDialogError && (
<Alert severity="error" sx={{ mb: 2, mt: 1 }}>
{bookingDialogError}
</Alert>
)}
<Stack spacing={2} sx={{ mt: 1 }}>
<FormControl fullWidth size="small" required>
<InputLabel>Fahrzeug</InputLabel>
<Select
value={bookingForm.fahrzeugId}
onChange={(e) =>
setBookingForm((f) => ({ ...f, fahrzeugId: e.target.value }))
}
label="Fahrzeug"
>
{vehicles.map((v) => (
<MenuItem key={v.id} value={v.id}>
{v.bezeichnung}{v.amtliches_kennzeichen ? ` (${v.amtliches_kennzeichen})` : ''}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
fullWidth size="small" label="Titel" required
value={bookingForm.titel}
onChange={(e) =>
setBookingForm((f) => ({ ...f, titel: e.target.value }))
}
/>
<TextField
fullWidth size="small" label="Beginn" placeholder="TT.MM.JJJJ HH:MM" required
value={bookingForm.beginn}
onChange={(e) =>
setBookingForm((f) => ({ ...f, beginn: e.target.value }))
}
InputLabelProps={{ shrink: true }}
/>
<TextField
fullWidth size="small" label="Ende" placeholder="TT.MM.JJJJ HH:MM" required
value={bookingForm.ende}
onChange={(e) =>
setBookingForm((f) => ({ ...f, ende: e.target.value }))
}
InputLabelProps={{ shrink: true }}
/>
{availability !== null && (
<Chip
icon={availability ? <CheckIcon /> : <Warning />}
label={availability ? 'Fahrzeug verfügbar' : 'Konflikt: bereits gebucht'}
color={availability ? 'success' : 'error'}
size="small"
sx={{ alignSelf: 'flex-start' }}
/>
)}
<FormControl fullWidth size="small">
<InputLabel>Buchungsart</InputLabel>
<Select
value={bookingForm.buchungsArt}
onChange={(e) =>
setBookingForm((f) => ({
...f,
buchungsArt: e.target.value as BuchungsArt,
}))
}
label="Buchungsart"
>
{(Object.entries(BUCHUNGS_ART_LABELS) as [BuchungsArt, string][]).map(
([art, label]) => (
<MenuItem key={art} value={art}>
{label}
</MenuItem>
)
)}
</Select>
</FormControl>
{bookingForm.buchungsArt === 'extern' && (
<>
<TextField
fullWidth size="small" label="Kontaktperson"
value={bookingForm.kontaktPerson || ''}
onChange={(e) =>
setBookingForm((f) => ({ ...f, kontaktPerson: e.target.value }))
}
/>
<TextField
fullWidth size="small" label="Kontakttelefon"
value={bookingForm.kontaktTelefon || ''}
onChange={(e) =>
setBookingForm((f) => ({ ...f, kontaktTelefon: e.target.value }))
}
/>
</>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setBookingDialogOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleBookingSave}
disabled={
bookingDialogLoading ||
!bookingForm.titel ||
!bookingForm.fahrzeugId ||
!bookingForm.beginn ||
!bookingForm.ende
}
>
{bookingDialogLoading ? (
<CircularProgress size={20} />
) : editingBooking ? (
'Speichern'
) : (
'Buchen'
)}
</Button>
</DialogActions>
</Dialog>
{/* Cancel booking dialog */}
<Dialog
open={Boolean(cancelBookingId)}
onClose={() => setCancelBookingId(null)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Buchung stornieren</DialogTitle>
<DialogContent>
<TextField
fullWidth multiline rows={3} label="Stornierungsgrund"
value={cancelBookingGrund}
onChange={(e) => setCancelBookingGrund(e.target.value)}
sx={{ mt: 1 }}
helperText={`${cancelBookingGrund.length}/1000 (min. 5 Zeichen)`}
inputProps={{ maxLength: 1000 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setCancelBookingId(null)} disabled={cancelBookingLoading}>
Abbrechen
</Button>
<Button
variant="contained"
color="error"
onClick={handleBookingCancel}
disabled={cancelBookingGrund.length < 5 || cancelBookingLoading}
startIcon={cancelBookingLoading ? <CircularProgress size={16} /> : undefined}
>
Stornieren
</Button>
</DialogActions>
</Dialog>
{/* iCal Booking subscription dialog */}
<Dialog open={icalBookingOpen} onClose={() => setIcalBookingOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Kalender abonnieren</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Abonniere den Fahrzeugbuchungskalender in deiner Kalenderanwendung. Kopiere die URL und füge sie als neuen
Kalender (per URL) in Apple Kalender, Google Kalender oder Outlook ein.
</Typography>
<TextField
fullWidth
value={icalBookingUrl}
InputProps={{
readOnly: true,
endAdornment: (
<IconButton
onClick={() => {
navigator.clipboard.writeText(icalBookingUrl);
notification.showSuccess('URL kopiert!');
}}
>
<CopyIcon />
</IconButton>
),
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setIcalBookingOpen(false)}>Schließen</Button>
</DialogActions>
</Dialog>
</Box>
)}
</Box>
{/* CSV Import Dialog */}
<CsvImportDialog
open={csvImportOpen}
onClose={() => setCsvImportOpen(false)}
onImported={() => {
setCsvImportOpen(false);
loadCalendarData();
}}
/>
</DashboardLayout>
);
}