diff --git a/backend/src/services/booking.service.ts b/backend/src/services/booking.service.ts
index 04a087c..42173d1 100644
--- a/backend/src/services/booking.service.ts
+++ b/backend/src/services/booking.service.ts
@@ -79,7 +79,7 @@ class BookingService {
b.id, b.fahrzeug_id, b.titel, b.buchungs_art::text AS buchungs_art,
b.beginn, b.ende, b.abgesagt,
f.name AS fahrzeug_name, f.kennzeichen AS fahrzeug_kennzeichen,
- u.display_name AS gebucht_von_name
+ u.name AS gebucht_von_name
FROM fahrzeug_buchungen b
JOIN fahrzeuge f ON f.id = b.fahrzeug_id
JOIN users u ON u.id = b.gebucht_von
@@ -104,7 +104,7 @@ class BookingService {
b.id, b.fahrzeug_id, b.titel, b.buchungs_art::text AS buchungs_art,
b.beginn, b.ende, b.abgesagt,
f.name AS fahrzeug_name, f.kennzeichen AS fahrzeug_kennzeichen,
- u.display_name AS gebucht_von_name
+ u.name AS gebucht_von_name
FROM fahrzeug_buchungen b
JOIN fahrzeuge f ON f.id = b.fahrzeug_id
JOIN users u ON u.id = b.gebucht_von
@@ -129,7 +129,7 @@ class BookingService {
b.abgesagt, b.abgesagt_grund,
b.erstellt_am, b.aktualisiert_am,
f.name AS fahrzeug_name, f.kennzeichen AS fahrzeug_kennzeichen,
- u.display_name AS gebucht_von_name
+ u.name AS gebucht_von_name
FROM fahrzeug_buchungen b
JOIN fahrzeuge f ON f.id = b.fahrzeug_id
JOIN users u ON u.id = b.gebucht_von
diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx
index 3f2ffd0..c1df4c5 100644
--- a/frontend/src/components/shared/Sidebar.tsx
+++ b/frontend/src/components/shared/Sidebar.tsx
@@ -14,8 +14,6 @@ import {
Build,
People,
Air,
- Event,
- BookOnline,
CalendarMonth,
} from '@mui/icons-material';
import { useNavigate, useLocation } from 'react-router-dom';
@@ -39,21 +37,11 @@ const navigationItems: NavigationItem[] = [
icon: ,
path: '/kalender',
},
- {
- text: 'Veranstaltungen',
- icon: ,
- path: '/veranstaltungen',
- },
{
text: 'Fahrzeuge',
icon: ,
path: '/fahrzeuge',
},
- {
- text: 'Fahrzeugbuchungen',
- icon: ,
- path: '/fahrzeugbuchungen',
- },
{
text: 'Ausrüstung',
icon: ,
diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx
index ac38f34..80f2d90 100644
--- a/frontend/src/pages/Kalender.tsx
+++ b/frontend/src/pages/Kalender.tsx
@@ -1,49 +1,110 @@
-import { useState, useMemo, useCallback } from 'react';
+import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
+ Alert,
Box,
- Typography,
- IconButton,
- ButtonGroup,
Button,
- Popover,
+ ButtonGroup,
+ Checkbox,
+ Chip,
+ CircularProgress,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ DialogTitle,
+ Divider,
+ Fab,
+ FormControl,
+ FormControlLabel,
+ FormGroup,
+ IconButton,
+ InputLabel,
List,
ListItem,
ListItemText,
- Chip,
- Tooltip,
- Dialog,
- DialogTitle,
- DialogContent,
- DialogContentText,
- DialogActions,
- Snackbar,
- Alert,
+ MenuItem,
+ Paper,
+ Popover,
+ Select,
Skeleton,
- Divider,
- useTheme,
+ 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,
- Today as TodayIcon,
- CalendarViewMonth as CalendarIcon,
- ViewList as ListViewIcon,
- Star as StarIcon,
ContentCopy as CopyIcon,
- CheckCircle as CheckIcon,
- Cancel as CancelIcon,
+ DirectionsCar as CarIcon,
+ Edit as EditIcon,
+ Event as EventIcon,
HelpOutline as UnknownIcon,
+ Star as StarIcon,
+ Today as TodayIcon,
+ Tune,
+ ViewList as ListViewIcon,
+ Warning,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
-import { useQuery } from '@tanstack/react-query';
import DashboardLayout from '../components/dashboard/DashboardLayout';
+import { useAuth } from '../contexts/AuthContext';
+import { useNotification } from '../contexts/NotificationContext';
import { trainingApi } from '../services/training';
-import type { UebungListItem, UebungTyp, TeilnahmeStatus } from '../types/training.types';
+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,
+} 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 & helpers
-// ---------------------------------------------------------------------------
+// ──────────────────────────────────────────────────────────────────────────────
+// 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 = [
@@ -52,26 +113,45 @@ const MONTH_LABELS = [
];
const TYP_DOT_COLOR: Record = {
- 'Übungsabend': '#1976d2', // blue
- 'Lehrgang': '#7b1fa2', // purple
- 'Sonderdienst': '#e65100', // orange
- 'Versammlung': '#616161', // gray
- 'Gemeinschaftsübung': '#00796b', // teal
- 'Sonstiges': '#9e9e9e', // light gray
+ 'Übungsabend': '#1976d2',
+ 'Lehrgang': '#7b1fa2',
+ 'Sonderdienst': '#e65100',
+ 'Versammlung': '#616161',
+ 'Gemeinschaftsübung': '#00796b',
+ 'Sonstiges': '#9e9e9e',
};
-const TYP_CHIP_COLOR: Record<
- UebungTyp,
- 'primary' | 'secondary' | 'warning' | 'default' | 'error' | 'info' | 'success'
-> = {
- 'Übungsabend': 'primary',
- 'Lehrgang': 'secondary',
- 'Sonderdienst': 'warning',
- 'Versammlung': 'default',
- 'Gemeinschaftsübung': 'info',
- 'Sonstiges': 'default',
+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);
@@ -86,15 +166,11 @@ function sameDay(a: Date, b: Date): boolean {
);
}
-/** Returns calendar grid cells for the month view — always 6×7 (42 cells) */
function buildMonthGrid(year: number, month: number): Date[] {
- // month is 0-indexed
const firstDay = new Date(year, month, 1);
- // ISO week starts Monday; getDay() returns 0=Sun → convert to Mon=0
const dayOfWeek = (firstDay.getDay() + 6) % 7;
const start = new Date(firstDay);
start.setDate(start.getDate() - dayOfWeek);
-
const cells: Date[] = [];
for (let i = 0; i < 42; i++) {
const d = new Date(start);
@@ -106,172 +182,127 @@ function buildMonthGrid(year: number, month: number): Date[] {
function formatTime(isoString: string): string {
const d = new Date(isoString);
- const h = String(d.getHours()).padStart(2, '0');
- const m = String(d.getMinutes()).padStart(2, '0');
- return `${h}:${m}`;
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
-function formatDateLong(isoString: string): string {
- const d = new Date(isoString);
- const days = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
+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()}`;
}
-// ---------------------------------------------------------------------------
-// RSVP indicator
-// ---------------------------------------------------------------------------
+function toDatetimeLocal(isoString: string): string {
+ const d = new Date(isoString);
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+
+function fromDatetimeLocal(value: string): string {
+ 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 ;
- if (status === 'zugesagt' || status === 'erschienen') return ;
+ if (!status || status === 'unbekannt')
+ return ;
+ if (status === 'zugesagt' || status === 'erschienen')
+ return ;
return ;
}
-// ---------------------------------------------------------------------------
-// iCal Subscribe Dialog
-// ---------------------------------------------------------------------------
-
-interface IcalDialogProps {
- open: boolean;
- onClose: () => void;
-}
-
-function IcalDialog({ open, onClose }: IcalDialogProps) {
- const [snackOpen, setSnackOpen] = useState(false);
- const [subscribeUrl, setSubscribeUrl] = useState(null);
- const [loading, setLoading] = useState(false);
-
- const handleOpen = async () => {
- if (subscribeUrl) return;
- setLoading(true);
- try {
- const { subscribeUrl: url } = await trainingApi.getCalendarToken();
- setSubscribeUrl(url);
- } catch (_) {
- setSubscribeUrl(null);
- } finally {
- setLoading(false);
- }
- };
-
- const handleCopy = async () => {
- if (!subscribeUrl) return;
- await navigator.clipboard.writeText(subscribeUrl);
- setSnackOpen(true);
- };
-
- return (
- <>
-
-
- setSnackOpen(false)}
- anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
- >
- setSnackOpen(false)}>
- URL kopiert!
-
-
- >
- );
-}
-
-// ---------------------------------------------------------------------------
-// Month Calendar Grid
-// ---------------------------------------------------------------------------
+// ──────────────────────────────────────────────────────────────────────────────
+// Month Calendar (training + events)
+// ──────────────────────────────────────────────────────────────────────────────
interface MonthCalendarProps {
year: number;
month: number;
- events: UebungListItem[];
+ trainingEvents: UebungListItem[];
+ veranstaltungen: VeranstaltungListItem[];
+ selectedKategorie: string | 'all';
onDayClick: (day: Date, anchor: Element) => void;
}
-function MonthCalendar({ year, month, events, onDayClick }: MonthCalendarProps) {
+function MonthCalendar({
+ year, month, trainingEvents, veranstaltungen, selectedKategorie, onDayClick,
+}: MonthCalendarProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
- const today = startOfDay(new Date());
+ const todayDate = startOfDay(new Date());
const cells = useMemo(() => buildMonthGrid(year, month), [year, month]);
- // Build a map: "YYYY-MM-DD" → events
const eventsByDay = useMemo(() => {
- const map = new Map();
- for (const ev of events) {
- const d = startOfDay(new Date(ev.datum_von));
- const key = d.toISOString().slice(0, 10);
+ const map = new Map();
+
+ for (const t of trainingEvents) {
+ const key = startOfDay(new Date(t.datum_von)).toISOString().slice(0, 10);
const arr = map.get(key) ?? [];
- arr.push(ev);
+ 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;
- }, [events]);
+ }, [trainingEvents, veranstaltungen, selectedKategorie]);
return (
- {/* Weekday headers */}
-
+
{WEEKDAY_LABELS.map((wd) => (
- {/* Day cells — 6 rows × 7 cols */}
-
+
{cells.map((cell, idx) => {
const isCurrentMonth = cell.getMonth() === month;
- const isToday = sameDay(cell, today);
+ 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 (
- {/* Event dots — max 3 visible on mobile */}
{hasEvents && (
- {dayEvents.slice(0, isMobile ? 3 : 5).map((ev, i) => (
+ {dayEvents.slice(0, maxDots).map((ev, i) => (
))}
- {dayEvents.length > (isMobile ? 3 : 5) && (
+ {dayEvents.length > maxDots && (
- +{dayEvents.length - (isMobile ? 3 : 5)}
+ +{dayEvents.length - maxDots}
)}
)}
- {/* On desktop: show short event titles */}
{!isMobile && hasEvents && (
{dayEvents.slice(0, 2).map((ev, i) => (
@@ -400,12 +419,13 @@ function MonthCalendar({ year, month, events, onDayClick }: MonthCalendarProps)
display: 'block',
fontSize: '0.6rem',
lineHeight: 1.3,
- color: ev.abgesagt ? 'text.disabled' : TYP_DOT_COLOR[ev.typ],
+ color: ev.abgesagt ? 'text.disabled' : ev.color,
textDecoration: ev.abgesagt ? 'line-through' : 'none',
px: 0.25,
}}
>
- {ev.pflichtveranstaltung && '* '}{ev.titel}
+ {ev.kind === 'training' && ev.pflicht && '* '}
+ {ev.titel}
))}
@@ -424,132 +444,44 @@ function MonthCalendar({ year, month, events, onDayClick }: MonthCalendarProps)
))}
-
+
Pflichtveranstaltung
+
+
+ Veranstaltung
+
);
}
-// ---------------------------------------------------------------------------
-// List View
-// ---------------------------------------------------------------------------
-
-function ListView({
- events,
- onEventClick,
-}: {
- events: UebungListItem[];
- onEventClick: (id: string) => void;
-}) {
- return (
-
- {events.map((ev, idx) => (
-
- {idx > 0 && }
- onEventClick(ev.id)}
- sx={{
- cursor: 'pointer',
- px: 1,
- py: 1,
- borderRadius: 1,
- opacity: ev.abgesagt ? 0.55 : 1,
- '&:hover': { bgcolor: 'action.hover' },
- }}
- >
- {/* Date badge */}
-
-
- {new Date(ev.datum_von).getDate()}.
- {new Date(ev.datum_von).getMonth() + 1}.
-
-
- {formatTime(ev.datum_von)}
-
-
-
-
- {ev.pflichtveranstaltung && (
-
- )}
-
- {ev.titel}
-
- {ev.abgesagt && (
-
- )}
-
- }
- secondary={
-
-
- {ev.ort && (
-
- {ev.ort}
-
- )}
-
- }
- sx={{ my: 0 }}
- />
-
- {/* RSVP badge */}
-
-
-
-
-
- ))}
- {events.length === 0 && (
-
- Keine Veranstaltungen in diesem Monat.
-
- )}
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Day Popover
-// ---------------------------------------------------------------------------
+// ──────────────────────────────────────────────────────────────────────────────
+// Day Popover (unified)
+// ──────────────────────────────────────────────────────────────────────────────
interface DayPopoverProps {
anchorEl: Element | null;
day: Date | null;
- events: UebungListItem[];
+ trainingForDay: UebungListItem[];
+ eventsForDay: VeranstaltungListItem[];
+ canWriteEvents: boolean;
onClose: () => void;
- onEventClick: (id: string) => void;
+ onTrainingClick: (id: string) => void;
+ onEventEdit: (ev: VeranstaltungListItem) => void;
}
-function DayPopover({ anchorEl, day, events, onClose, onEventClick }: DayPopoverProps) {
+function DayPopover({
+ anchorEl, day, trainingForDay, eventsForDay,
+ canWriteEvents, onClose, onTrainingClick, onEventEdit,
+}: DayPopoverProps) {
if (!day) return null;
+ const hasContent = trainingForDay.length > 0 || eventsForDay.length > 0;
return (
-
- {formatDateLong(day.toISOString())}
+
+ {formatDateLong(day)}
-
- {events.map((ev) => (
- { onEventClick(ev.id); onClose(); }}
- sx={{
- cursor: 'pointer',
- borderRadius: 1,
- px: 0.75,
- '&:hover': { bgcolor: 'action.hover' },
- opacity: ev.abgesagt ? 0.6 : 1,
- }}
- >
-
-
+ Keine Einträge
+
+ )}
+
+ {trainingForDay.length > 0 && (
+ <>
+
+ Dienste
+
+
+ {trainingForDay.map((t) => (
+ { onTrainingClick(t.id); onClose(); }}
+ sx={{
+ cursor: 'pointer', borderRadius: 1, px: 0.75,
+ '&:hover': { bgcolor: 'action.hover' },
+ opacity: t.abgesagt ? 0.6 : 1,
+ }}
+ >
+
- {ev.pflichtveranstaltung && }
- {ev.titel}
-
- }
- secondary={`${formatTime(ev.datum_von)} – ${formatTime(ev.datum_bis)} Uhr`}
- />
-
-
- ))}
-
+ />
+
+ {t.pflichtveranstaltung && (
+
+ )}
+ {t.titel}
+
+ }
+ secondary={`${formatTime(t.datum_von)} – ${formatTime(t.datum_bis)} Uhr`}
+ />
+
+
+ ))}
+
+ >
+ )}
+
+ {trainingForDay.length > 0 && eventsForDay.length > 0 && (
+
+ )}
+
+ {eventsForDay.length > 0 && (
+ <>
+
+ Veranstaltungen
+
+
+ {eventsForDay.map((ev) => (
+
+
+
+ {ev.titel}
+ {ev.abgesagt && (
+
+ )}
+
+ }
+ secondary={
+ ev.ganztaegig
+ ? 'Ganztägig'
+ : `${formatTime(ev.datum_von)} – ${formatTime(ev.datum_bis)} Uhr`
+ }
+ />
+ {canWriteEvents && !ev.abgesagt && (
+ { onEventEdit(ev); onClose(); }}
+ sx={{ mt: 0.25 }}
+ >
+
+
+ )}
+
+ ))}
+
+ >
+ )}
);
}
-// ---------------------------------------------------------------------------
-// Main Page
-// ---------------------------------------------------------------------------
+// ──────────────────────────────────────────────────────────────────────────────
+// 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;
+}
+
+function CombinedListView({
+ trainingEvents, veranstaltungen, selectedKategorie,
+ canWriteEvents, onTrainingClick, onEventEdit, onEventCancel,
+}: 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 (
+
+ Keine Einträge in diesem Monat.
+
+ );
+ }
+
+ return (
+
+ {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 (
+
+ {idx > 0 && }
+ onTrainingClick(item.id) : undefined}
+ >
+ {/* Date badge */}
+
+
+ {new Date(item.datum_von).getDate()}.
+ {new Date(item.datum_von).getMonth() + 1}.
+
+
+ {formatTime(item.datum_von)}
+
+
+
+
+ {isTraining && (item as UebungListItem).pflichtveranstaltung && (
+
+ )}
+
+ {item.titel}
+
+ {item.abgesagt && (
+
+ )}
+
+ }
+ secondary={
+
+
+ {(item as { ort?: string | null }).ort && (
+
+ {(item as { ort?: string | null }).ort}
+
+ )}
+ {isTraining && (
+
+
+
+ )}
+
+ }
+ sx={{ my: 0 }}
+ />
+
+ {!isTraining && canWriteEvents && !item.abgesagt && (
+
+ onEventEdit(item as VeranstaltungListItem)}>
+
+
+ onEventCancel(item.id)}>
+
+
+
+ )}
+
+
+ );
+ })}
+
+ );
+}
+
+// ──────────────────────────────────────────────────────────────────────────────
+// 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({ ...EMPTY_VERANSTALTUNG_FORM });
+
+ 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,
+ });
+ } 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() });
+ }
+ }, [open, editingEvent]);
+
+ const handleChange = (field: keyof CreateVeranstaltungInput, value: unknown) => {
+ 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 {
+ if (editingEvent) {
+ await eventsApi.updateEvent(editingEvent.id, form);
+ notification.showSuccess('Veranstaltung aktualisiert');
+ } else {
+ await eventsApi.createEvent(form);
+ notification.showSuccess('Veranstaltung erstellt');
+ }
+ onSaved();
+ onClose();
+ } catch (e: unknown) {
+ notification.showError(e instanceof Error ? e.message : 'Fehler beim Speichern');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
+// ──────────────────────────────────────────────────────────────────────────────
+// 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 today = new Date();
- const [viewMonth, setViewMonth] = useState({ year: today.getFullYear(), month: today.getMonth() });
- const [viewMode, setViewMode] = useState<'calendar' | 'list'>('calendar');
- const [icalOpen, setIcalOpen] = useState(false);
+ const canWriteEvents =
+ user?.groups?.some((g) => WRITE_GROUPS_EVENTS.includes(g)) ?? false;
+ const canWriteBookings =
+ user?.groups?.some((g) => WRITE_GROUPS_BOOKINGS.includes(g)) ?? false;
- // Popover state
+ // ── 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('all');
+
+ const [trainingEvents, setTrainingEvents] = useState([]);
+ const [veranstaltungen, setVeranstaltungen] = useState([]);
+ const [kategorien, setKategorien] = useState([]);
+ const [groups, setGroups] = useState([]);
+ const [calLoading, setCalLoading] = useState(false);
+ const [calError, setCalError] = useState(null);
+
+ // Popover state (day click)
const [popoverAnchor, setPopoverAnchor] = useState(null);
const [popoverDay, setPopoverDay] = useState(null);
- const [popoverEvents, setPopoverEvents] = useState([]);
- // Compute fetch range: whole month ± 1 week buffer for grid
- const { from, to } = useMemo(() => {
- const firstCell = new Date(viewMonth.year, viewMonth.month, 1);
- const dayOfWeek = (firstCell.getDay() + 6) % 7;
- const f = new Date(firstCell);
- f.setDate(f.getDate() - dayOfWeek);
+ // Veranstaltung form / cancel dialogs
+ const [veranstFormOpen, setVeranstFormOpen] = useState(false);
+ const [veranstEditing, setVeranstEditing] = useState(null);
+ const [cancelEventId, setCancelEventId] = useState(null);
+ const [cancelEventGrund, setCancelEventGrund] = useState('');
+ const [cancelEventLoading, setCancelEventLoading] = useState(false);
- const lastCell = new Date(f);
- lastCell.setDate(lastCell.getDate() + 41);
+ // ── Bookings tab state ───────────────────────────────────────────────────────
+ const [currentWeekStart, setCurrentWeekStart] = useState(() =>
+ 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')}`;
- return { from: f, to: lastCell };
+ const [vehicles, setVehicles] = useState([]);
+ const [bookings, setBookings] = useState([]);
+ const [bookingsLoading, setBookingsLoading] = useState(false);
+ const [bookingsError, setBookingsError] = useState(null);
+
+ // Booking detail popover
+ const [detailAnchor, setDetailAnchor] = useState(null);
+ const [detailBooking, setDetailBooking] = useState(null);
+
+ // Booking form dialog
+ const [bookingDialogOpen, setBookingDialogOpen] = useState(false);
+ const [editingBooking, setEditingBooking] = useState(null);
+ const [bookingForm, setBookingForm] = useState({ ...EMPTY_BOOKING_FORM });
+ const [bookingDialogLoading, setBookingDialogLoading] = useState(false);
+ const [bookingDialogError, setBookingDialogError] = useState(null);
+ const [availability, setAvailability] = useState(null);
+
+ // Cancel booking
+ const [cancelBookingId, setCancelBookingId] = useState(null);
+ const [cancelBookingGrund, setCancelBookingGrund] = useState('');
+ const [cancelBookingLoading, setCancelBookingLoading] = useState(false);
+
+ // ── 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]);
- const { data, isLoading } = useQuery({
- queryKey: ['training', 'calendar', from.toISOString(), to.toISOString()],
- queryFn: () => trainingApi.getCalendarRange(from, to),
- staleTime: 5 * 60 * 1000,
- });
+ // Load kategorien + groups once
+ useEffect(() => {
+ Promise.all([eventsApi.getKategorien(), eventsApi.getGroups()]).then(
+ ([kat, grp]) => {
+ setKategorien(kat);
+ setGroups(grp);
+ }
+ );
+ }, []);
- const events = useMemo(() => data ?? [], [data]);
+ 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) => {
@@ -672,133 +1169,882 @@ export default function Kalender() {
setViewMonth({ year: today.getFullYear(), month: today.getMonth() });
};
- const handleDayClick = useCallback((day: Date, anchor: Element) => {
- const key = day.toISOString().slice(0, 10);
- const dayEvs = events.filter(
- (ev) => startOfDay(new Date(ev.datum_von)).toISOString().slice(0, 10) === key
+ 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)
);
- setPopoverDay(day);
- setPopoverAnchor(anchor);
- setPopoverEvents(dayEvs);
- }, [events]);
+ }, [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 instanceof Error ? e.message : 'Fehler beim Absagen');
+ } finally {
+ setCancelEventLoading(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 (!canWriteBookings) return;
+ setEditingBooking(null);
+ setBookingForm({
+ ...EMPTY_BOOKING_FORM,
+ fahrzeugId: vehicleId,
+ beginn: fnsFormat(day, "yyyy-MM-dd'T'08:00"),
+ ende: 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: fnsFormat(parseISO(detailBooking.beginn as unknown as string), "yyyy-MM-dd'T'HH:mm"),
+ ende: 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;
+ bookingApi
+ .checkAvailability(
+ bookingForm.fahrzeugId,
+ new Date(bookingForm.beginn),
+ new Date(bookingForm.ende)
+ )
+ .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: new Date(bookingForm.beginn).toISOString(),
+ ende: 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);
+ }
+ };
+
+ // ── Render ───────────────────────────────────────────────────────────────────
return (
-
+
{/* Page header */}
-
+
-
- Dienstkalender
+
+ Kalender
-
- {/* View toggle */}
-
-
-
-
-
-
-
-
-
- }
- onClick={() => setIcalOpen(true)}
- sx={{ whiteSpace: 'nowrap' }}
- >
- {isMobile ? 'iCal' : 'Kalender abonnieren'}
-
- {/* Month navigation */}
- setActiveTab(v)}
+ sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}
>
-
-
-
+ } iconPosition="start" label="Dienste & Veranstaltungen" />
+ } iconPosition="start" label="Fahrzeugbuchungen" />
+
-
- {MONTH_LABELS[viewMonth.month]} {viewMonth.year}
-
+ {/* ── TAB 0: Calendar ───────────────────────────────────────────── */}
+ {activeTab === 0 && (
+
+ {/* Controls row */}
+
+ {/* View toggle */}
+
+
+
+
+
+
+
+
- }
- onClick={handleToday}
- sx={{ minWidth: 'auto' }}
- >
- {!isMobile && 'Heute'}
-
+ {/* Category filter */}
+ {kategorien.length > 0 && (
+
+ setSelectedKategorie('all')}
+ color={selectedKategorie === 'all' ? 'primary' : 'default'}
+ variant={selectedKategorie === 'all' ? 'filled' : 'outlined'}
+ size="small"
+ />
+ {kategorien.map((k) => (
+
+ 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'}
+ />
+ ))}
+
+ )}
-
-
-
-
+ {/* Kategorien verwalten */}
+ {canWriteEvents && (
+
+ navigate('/veranstaltungen/kategorien')}
+ >
+
+
+
+ )}
+
- {/* Calendar / List body */}
- {isLoading ? (
-
- ) : viewMode === 'calendar' ? (
-
- ) : (
- {
- const d = new Date(ev.datum_von);
- return d.getMonth() === viewMonth.month && d.getFullYear() === viewMonth.year;
- })}
- onEventClick={(id) => navigate(`/training/${id}`)}
- />
+ {/* Month navigation */}
+
+
+
+
+
+ {MONTH_LABELS[viewMonth.month]} {viewMonth.year}
+
+ }
+ onClick={handleToday}
+ sx={{ minWidth: 'auto' }}
+ >
+ {!isMobile && 'Heute'}
+
+
+
+
+
+
+ {calError && (
+ setCalError(null)}>
+ {calError}
+
+ )}
+
+ {/* Calendar / List body */}
+ {calLoading ? (
+
+ ) : viewMode === 'calendar' ? (
+
+
+
+ ) : (
+
+ navigate(`/training/${id}`)}
+ onEventEdit={(ev) => {
+ setVeranstEditing(ev);
+ setVeranstFormOpen(true);
+ }}
+ onEventCancel={(id) => {
+ setCancelEventId(id);
+ setCancelEventGrund('');
+ }}
+ />
+
+ )}
+
+ {/* FAB: Create Veranstaltung */}
+ {canWriteEvents && (
+ {
+ setVeranstEditing(null);
+ setVeranstFormOpen(true);
+ }}
+ >
+
+
+ )}
+
+ {/* Day Popover */}
+ setPopoverAnchor(null)}
+ onTrainingClick={(id) => navigate(`/training/${id}`)}
+ onEventEdit={(ev) => {
+ setVeranstEditing(ev);
+ setVeranstFormOpen(true);
+ }}
+ />
+
+ {/* Veranstaltung Form Dialog */}
+ { setVeranstFormOpen(false); setVeranstEditing(null); }}
+ onSaved={loadCalendarData}
+ editingEvent={veranstEditing}
+ kategorien={kategorien}
+ groups={groups}
+ />
+
+ {/* Veranstaltung Cancel Dialog */}
+
+
)}
+
+ {/* ── TAB 1: Fahrzeugbuchungen ──────────────────────────────────── */}
+ {activeTab === 1 && (
+
+ {/* Week navigation */}
+
+
+ setCurrentWeekStart((d) => subWeeks(d, 1))}
+ >
+
+
+
+ {weekLabel}
+
+ setCurrentWeekStart((d) => addWeeks(d, 1))}
+ >
+
+
+ }
+ onClick={() =>
+ setCurrentWeekStart(startOfWeek(new Date(), { weekStartsOn: 1 }))
+ }
+ >
+ Heute
+
+
+
+ {canWriteBookings && (
+ }
+ size="small"
+ onClick={openBookingCreate}
+ >
+ Neue Buchung
+
+ )}
+
+
+ {bookingsLoading && (
+
+
+
+ )}
+ {!bookingsLoading && bookingsError && (
+
+ {bookingsError}
+
+ )}
+
+ {!bookingsLoading && !bookingsError && (
+ <>
+
+
+
+
+
+ Fahrzeug
+
+ {weekDays.map((day) => (
+
+
+ {fnsFormat(day, 'EEE', { locale: de })}
+
+
+ {fnsFormat(day, 'dd.MM.')}
+
+
+ ))}
+
+
+
+ {vehicles.map((vehicle) => (
+
+
+
+ {vehicle.name}
+
+ {vehicle.kennzeichen && (
+
+ {vehicle.kennzeichen}
+
+ )}
+
+ {weekDays.map((day) => {
+ const cellBookings = getBookingsForCell(vehicle.id, day);
+ const isFree = cellBookings.length === 0;
+ return (
+
+ isFree ? handleCellClick(vehicle.id, day) : undefined
+ }
+ sx={{
+ bgcolor: isFree ? 'success.50' : undefined,
+ cursor: isFree && canWriteBookings ? 'pointer' : 'default',
+ '&:hover':
+ isFree && canWriteBookings
+ ? { bgcolor: 'success.100' }
+ : {},
+ p: 0.5,
+ verticalAlign: 'top',
+ }}
+ >
+ {cellBookings.map((b) => (
+
+ 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',
+ }}
+ />
+
+ ))}
+
+ );
+ })}
+
+ ))}
+ {vehicles.length === 0 && (
+
+
+
+ Keine aktiven Fahrzeuge
+
+
+
+ )}
+
+
+
+
+ {/* Legend */}
+
+
+
+ Frei
+
+ {(Object.entries(BUCHUNGS_ART_LABELS) as [BuchungsArt, string][]).map(
+ ([art, label]) => (
+
+
+ {label}
+
+ )
+ )}
+
+ >
+ )}
+
+ {/* FAB */}
+ {canWriteBookings && (
+
+
+
+ )}
+
+ {/* Booking detail popover */}
+ { setDetailAnchor(null); setDetailBooking(null); }}
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
+ transformOrigin={{ vertical: 'top', horizontal: 'center' }}
+ >
+ {detailBooking && (
+
+
+ {detailBooking.titel}
+
+
+
+ {fnsFormat(parseISO(detailBooking.beginn), 'dd.MM.yyyy HH:mm')}
+ {' – '}
+ {fnsFormat(parseISO(detailBooking.ende), 'dd.MM.yyyy HH:mm')}
+
+ {detailBooking.gebucht_von_name && (
+
+ Von: {detailBooking.gebucht_von_name}
+
+ )}
+ {canWriteBookings && (
+
+ } onClick={handleOpenBookingEdit}>
+ Bearbeiten
+
+ }
+ onClick={() => {
+ if (!detailBooking) return;
+ setCancelBookingId(detailBooking.id);
+ setCancelBookingGrund('');
+ setDetailAnchor(null);
+ setDetailBooking(null);
+ }}
+ >
+ Stornieren
+
+
+ )}
+
+ )}
+
+
+ {/* Booking create/edit dialog */}
+
+
+ {/* Cancel booking dialog */}
+
+
+ )}
+
-
- {/* Day Popover */}
- setPopoverAnchor(null)}
- onEventClick={(id) => navigate(`/training/${id}`)}
- />
-
- {/* iCal Subscribe Dialog */}
- setIcalOpen(false)} />
);
}