From 314b6c3bed09aee8fec450ec277190bcc508b438 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Mon, 2 Mar 2026 16:59:24 +0100 Subject: [PATCH] featuer change for calendar --- backend/src/services/booking.service.ts | 6 +- frontend/src/components/shared/Sidebar.tsx | 12 - frontend/src/pages/Kalender.tsx | 2266 +++++++++++++++----- 3 files changed, 1759 insertions(+), 525 deletions(-) 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 ( - <> - - Kalender abonnieren - - - Kopiere die URL und füge sie in deiner Kalender-App unter - "Kalender abonnieren" ein. Der Kalender wird automatisch - aktualisiert, sobald neue Dienste eingetragen werden. - - - {loading && } - - {!loading && subscribeUrl && ( - - {subscribeUrl} - - - - - - - )} - - - Apple Kalender: Ablage → Neues Kalenderabonnement
- Google Kalender: Andere Kalender → Per URL
- Thunderbird: Neu → Kalender → Im Netzwerk -
-
- - - {subscribeUrl && ( - - )} - -
- - 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 ( + + + {editingEvent ? 'Veranstaltung bearbeiten' : 'Neue Veranstaltung'} + + + + handleChange('titel', e.target.value)} + required + fullWidth + /> + handleChange('beschreibung', e.target.value || null)} + multiline + rows={2} + fullWidth + /> + + Kategorie + + + handleChange('ganztaegig', e.target.checked)} + /> + } + label="Ganztägig" + /> + { + const iso = form.ganztaegig + ? fromDatetimeLocal(`${e.target.value}T00:00`) + : fromDatetimeLocal(e.target.value); + handleChange('datum_von', iso); + }} + InputLabelProps={{ shrink: true }} + fullWidth + /> + { + const iso = form.ganztaegig + ? fromDatetimeLocal(`${e.target.value}T23:59`) + : fromDatetimeLocal(e.target.value); + handleChange('datum_bis', iso); + }} + InputLabelProps={{ shrink: true }} + fullWidth + /> + handleChange('ort', e.target.value || null)} + fullWidth + /> + + handleChange('alle_gruppen', e.target.checked)} + /> + } + label="Für alle Mitglieder sichtbar" + /> + {!form.alle_gruppen && groups.length > 0 && ( + + + Zielgruppen + + + {groups.map((g) => ( + handleGroupToggle(g.id)} + size="small" + /> + } + label={g.label} + /> + ))} + + + )} + + handleChange('anmeldung_erforderlich', e.target.checked)} + /> + } + label="Anmeldung erforderlich" + /> + {form.anmeldung_erforderlich && ( + + handleChange('anmeldung_bis', e.target.value ? fromDatetimeLocal(e.target.value) : null) + } + InputLabelProps={{ shrink: true }} + fullWidth + /> + )} + + + + + + + + ); +} + +// ────────────────────────────────────────────────────────────────────────────── +// 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 */} - - - - - - - - - - - {/* 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 */} + + + + + + + + - + {/* 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} + + + + + + + + {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 */} + setCancelEventId(null)} + maxWidth="xs" + fullWidth + > + Veranstaltung absagen + + + Bitte gib einen Grund für die Absage an (mind. 5 Zeichen). + + setCancelEventGrund(e.target.value)} + autoFocus + /> + + + + + + + )} + + {/* ── TAB 1: Fahrzeugbuchungen ──────────────────────────────────── */} + {activeTab === 1 && ( + + {/* Week navigation */} + + + setCurrentWeekStart((d) => subWeeks(d, 1))} + > + + + + {weekLabel} + + setCurrentWeekStart((d) => addWeeks(d, 1))} + > + + + + + + {canWriteBookings && ( + + )} + + + {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 && ( + + + + + )} + + )} + + + {/* Booking create/edit dialog */} + setBookingDialogOpen(false)} + maxWidth="sm" + fullWidth + > + + {editingBooking ? 'Buchung bearbeiten' : 'Neue Buchung'} + + + {bookingDialogError && ( + + {bookingDialogError} + + )} + + + Fahrzeug + + + + + setBookingForm((f) => ({ ...f, titel: e.target.value })) + } + /> + + + setBookingForm((f) => ({ ...f, beginn: e.target.value })) + } + InputLabelProps={{ shrink: true }} + /> + + + setBookingForm((f) => ({ ...f, ende: e.target.value })) + } + InputLabelProps={{ shrink: true }} + /> + + {availability !== null && ( + : } + label={availability ? 'Fahrzeug verfügbar' : 'Konflikt: bereits gebucht'} + color={availability ? 'success' : 'error'} + size="small" + sx={{ alignSelf: 'flex-start' }} + /> + )} + + + Buchungsart + + + + {bookingForm.buchungsArt === 'extern' && ( + <> + + setBookingForm((f) => ({ ...f, kontaktPerson: e.target.value })) + } + /> + + setBookingForm((f) => ({ ...f, kontaktTelefon: e.target.value })) + } + /> + + )} + + + + + + + + + {/* Cancel booking dialog */} + setCancelBookingId(null)} + maxWidth="xs" + fullWidth + > + Buchung stornieren + + setCancelBookingGrund(e.target.value)} + sx={{ mt: 1 }} + helperText={`${cancelBookingGrund.length}/1000 (min. 5 Zeichen)`} + inputProps={{ maxLength: 1000 }} + /> + + + + + + + + )} + - - {/* Day Popover */} - setPopoverAnchor(null)} - onEventClick={(id) => navigate(`/training/${id}`)} - /> - - {/* iCal Subscribe Dialog */} - setIcalOpen(false)} />
); }