2700 lines
101 KiB
TypeScript
2700 lines
101 KiB
TypeScript
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>
|
||
);
|
||
}
|