import React, { useState, useEffect, useCallback } from 'react'; import { Box, Container, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Chip, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Select, MenuItem, FormControl, InputLabel, Fab, CircularProgress, Alert, Popover, Stack, Tooltip, } from '@mui/material'; import { Add, ChevronLeft, ChevronRight, Today, ContentCopy, Cancel, Edit, IosShare, CheckCircle, Warning, Block, } from '@mui/icons-material'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import ChatAwareFab from '../components/shared/ChatAwareFab'; import { useAuth } from '../contexts/AuthContext'; import { useNotification } from '../contexts/NotificationContext'; import { bookingApi, fetchVehicles } from '../services/bookings'; import type { FahrzeugBuchungListItem, Fahrzeug, CreateBuchungInput, BuchungsArt, } from '../types/booking.types'; import { BUCHUNGS_ART_LABELS, BUCHUNGS_ART_COLORS } from '../types/booking.types'; import { format, startOfWeek, endOfWeek, addWeeks, subWeeks, eachDayOfInterval, isToday, parseISO, isSameDay, isWithinInterval, } from 'date-fns'; import { de } from 'date-fns/locale'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const EMPTY_FORM: CreateBuchungInput = { fahrzeugId: '', titel: '', beschreibung: '', beginn: '', ende: '', buchungsArt: 'intern', kontaktPerson: '', kontaktTelefon: '', }; const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_moderator']; const MANAGE_ART_GROUPS = ['dashboard_admin', 'dashboard_moderator']; // --------------------------------------------------------------------------- // Main Page // --------------------------------------------------------------------------- function FahrzeugBuchungen() { const { user } = useAuth(); const notification = useNotification(); const canCreate = !!user; // All authenticated users can create bookings const canWrite = user?.groups?.some((g) => WRITE_GROUPS.includes(g)) ?? false; // Can edit/cancel const canChangeBuchungsArt = user?.groups?.some((g) => MANAGE_ART_GROUPS.includes(g)) ?? false; // Can change booking type // ── Week navigation ──────────────────────────────────────────────────────── 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 ${format(currentWeekStart, 'w')} · ${format( currentWeekStart, 'dd.MM.' )} – ${format(weekEnd, 'dd.MM.yyyy')}`; // ── Data ────────────────────────────────────────────────────────────────── const [vehicles, setVehicles] = useState([]); const [bookings, setBookings] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const loadData = useCallback(async () => { setLoading(true); try { const end = endOfWeek(currentWeekStart, { weekStartsOn: 1 }); const [vehiclesData, bookingsData] = await Promise.all([ fetchVehicles(), bookingApi.getCalendarRange(currentWeekStart, end), ]); setVehicles(vehiclesData); setBookings(bookingsData); setError(null); } catch (e: unknown) { const msg = e instanceof Error ? e.message : 'Fehler beim Laden'; setError(msg); } finally { setLoading(false); } }, [currentWeekStart]); useEffect(() => { loadData(); }, [loadData]); // ── Cell helper ─────────────────────────────────────────────────────────── const getBookingsForCell = ( vehicleId: string, day: Date ): FahrzeugBuchungListItem[] => { return 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 isOutOfService = (vehicle: Fahrzeug, day: Date): boolean => { if (!vehicle.ausser_dienst_von || !vehicle.ausser_dienst_bis) return false; try { return isWithinInterval(day, { start: parseISO(vehicle.ausser_dienst_von), end: parseISO(vehicle.ausser_dienst_bis), }); } catch { return false; } }; // ── Create / Edit dialog ────────────────────────────────────────────────── const [dialogOpen, setDialogOpen] = useState(false); const [editingBooking, setEditingBooking] = useState(null); const [form, setForm] = useState({ ...EMPTY_FORM }); const [dialogLoading, setDialogLoading] = useState(false); const [dialogError, setDialogError] = useState(null); const [availability, setAvailability] = useState<{ available: boolean; reason?: string; ausserDienstBis?: string; } | null>(null); // Check availability whenever the relevant form fields change useEffect(() => { if (!form.fahrzeugId || !form.beginn || !form.ende) { setAvailability(null); return; } let cancelled = false; bookingApi .checkAvailability( form.fahrzeugId, new Date(form.beginn), new Date(form.ende) ) .then((result) => { if (!cancelled) setAvailability(result); }) .catch(() => { if (!cancelled) setAvailability(null); }); return () => { cancelled = true; }; }, [form.fahrzeugId, form.beginn, form.ende]); const openCreateDialog = () => { setEditingBooking(null); setForm({ ...EMPTY_FORM }); setDialogError(null); setAvailability(null); setDialogOpen(true); }; const handleCellClick = (vehicleId: string, day: Date) => { if (!canCreate) return; const dateStr = format(day, "yyyy-MM-dd'T'08:00"); const dateEndStr = format(day, "yyyy-MM-dd'T'17:00"); setEditingBooking(null); setForm({ ...EMPTY_FORM, fahrzeugId: vehicleId, beginn: dateStr, ende: dateEndStr }); setDialogError(null); setAvailability(null); setDialogOpen(true); }; const handleSave = async () => { setDialogLoading(true); setDialogError(null); try { const payload: CreateBuchungInput = { ...form, beginn: new Date(form.beginn).toISOString(), ende: new Date(form.ende).toISOString(), }; if (editingBooking) { await bookingApi.update(editingBooking.id, payload); notification.showSuccess('Buchung aktualisiert'); } else { await bookingApi.create(payload); notification.showSuccess('Buchung erstellt'); } setDialogOpen(false); loadData(); } catch (e: unknown) { const axiosError = e as { response?: { status?: number; data?: { message?: string; reason?: string } }; message?: string }; if (axiosError?.response?.status === 409) { const reason = axiosError?.response?.data?.reason; if (reason === 'out_of_service') { setDialogError(axiosError?.response?.data?.message || 'Fahrzeug ist im gewählten Zeitraum außer Dienst'); } else { setDialogError('Fahrzeug ist im gewählten Zeitraum bereits gebucht'); } } else { setDialogError(axiosError?.message || 'Fehler beim Speichern'); } } finally { setDialogLoading(false); } }; // ── Cancel dialog ───────────────────────────────────────────────────────── const [cancelId, setCancelId] = useState(null); const [cancelGrund, setCancelGrund] = useState(''); const [cancelLoading, setCancelLoading] = useState(false); const handleCancel = async () => { if (!cancelId) return; setCancelLoading(true); try { await bookingApi.cancel(cancelId, cancelGrund); notification.showSuccess('Buchung storniert'); setCancelId(null); setDetailAnchor(null); setDetailBooking(null); loadData(); } catch (e: unknown) { const axiosErr = e as { response?: { data?: { message?: string } }; message?: string }; const msg = axiosErr?.response?.data?.message || (e instanceof Error ? e.message : 'Fehler beim Stornieren'); notification.showError(msg); } finally { setCancelLoading(false); } }; // ── Detail popover ──────────────────────────────────────────────────────── const [detailAnchor, setDetailAnchor] = useState(null); const [detailBooking, setDetailBooking] = useState(null); const handleBookingClick = ( e: React.MouseEvent, booking: FahrzeugBuchungListItem ) => { e.stopPropagation(); setDetailBooking(booking); setDetailAnchor(e.currentTarget); }; const handleOpenEdit = () => { if (!detailBooking) return; setEditingBooking(detailBooking); setForm({ fahrzeugId: detailBooking.fahrzeug_id, titel: detailBooking.titel, beschreibung: '', beginn: format(parseISO(detailBooking.beginn), "yyyy-MM-dd'T'HH:mm"), ende: format(parseISO(detailBooking.ende), "yyyy-MM-dd'T'HH:mm"), buchungsArt: detailBooking.buchungs_art, kontaktPerson: '', kontaktTelefon: '', }); setDialogError(null); setAvailability(null); setDialogOpen(true); setDetailAnchor(null); setDetailBooking(null); }; const handleOpenCancel = () => { if (!detailBooking) return; setCancelId(detailBooking.id); setCancelGrund(''); setDetailAnchor(null); setDetailBooking(null); }; // ── iCal dialog ─────────────────────────────────────────────────────────── const [icalOpen, setIcalOpen] = useState(false); const [icalUrl, setIcalUrl] = useState(''); const handleIcalOpen = async () => { try { const { subscribeUrl } = await bookingApi.getCalendarToken(); setIcalUrl(subscribeUrl); setIcalOpen(true); } catch (e: unknown) { const msg = e instanceof Error ? e.message : 'Fehler beim Laden des Tokens'; notification.showError(msg); } }; // ── Render ──────────────────────────────────────────────────────────────── return ( {/* ── Header ── */} Fahrzeugbuchungen {canCreate && ( )} {/* ── Week navigation ── */} setCurrentWeekStart((d) => subWeeks(d, 1))}> {weekLabel} setCurrentWeekStart((d) => addWeeks(d, 1))}> {/* ── Loading / error ── */} {loading && ( )} {!loading && error && ( {error} )} {/* ── Timeline table ── */} {!loading && !error && ( theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100' }}> Fahrzeug {weekDays.map((day) => ( theme.palette.mode === 'dark' ? 'primary.900' : 'primary.50' : undefined, }} > {format(day, 'EEE', { locale: de })} {format(day, 'dd.MM.')} ))} {vehicles.map((vehicle) => ( {vehicle.bezeichnung} {vehicle.amtliches_kennzeichen && ( {vehicle.amtliches_kennzeichen} )} {weekDays.map((day) => { const cellBookings = getBookingsForCell(vehicle.id, day); const oos = isOutOfService(vehicle, day); const isFree = cellBookings.length === 0 && !oos; return ( isFree ? handleCellClick(vehicle.id, day) : undefined } sx={{ bgcolor: oos ? (theme) => theme.palette.mode === 'dark' ? 'error.900' : 'error.50' : isFree ? (theme) => theme.palette.mode === 'dark' ? 'success.900' : 'success.50' : undefined, cursor: isFree && canCreate ? 'pointer' : oos ? 'not-allowed' : 'default', '&:hover': isFree && canCreate ? { bgcolor: (theme) => theme.palette.mode === 'dark' ? 'success.800' : 'success.100' } : {}, p: 0.5, verticalAlign: 'top', }} > {oos && ( } label="Außer Dienst" size="small" color="error" variant="outlined" sx={{ fontSize: '0.6rem', height: 18, mb: 0.25, width: '100%' }} /> )} {cellBookings.map((b) => ( 12 ? b.titel.slice(0, 12) + '…' : b.titel } size="small" onClick={(e) => handleBookingClick(e, b)} 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 ── */} {!loading && !error && ( theme.palette.mode === 'dark' ? 'success.900' : 'success.50', border: '1px solid', borderColor: (theme) => theme.palette.mode === 'dark' ? 'success.700' : 'success.300', borderRadius: 0.5, }} /> Frei theme.palette.mode === 'dark' ? 'error.900' : 'error.50', border: '1px solid', borderColor: (theme) => theme.palette.mode === 'dark' ? 'error.700' : 'error.300', borderRadius: 0.5, }} /> Außer Dienst {(Object.entries(BUCHUNGS_ART_LABELS) as [BuchungsArt, string][]).map( ([art, label]) => ( {label} ) )} )} {/* ── FAB ── */} {canCreate && ( )} {/* ── Booking detail popover ── */} { setDetailAnchor(null); setDetailBooking(null); }} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} transformOrigin={{ vertical: 'top', horizontal: 'center' }} > {detailBooking && ( {detailBooking.titel} {format(parseISO(detailBooking.beginn), 'dd.MM.yyyy HH:mm')} {' – '} {format(parseISO(detailBooking.ende), 'dd.MM.yyyy HH:mm')} {detailBooking.gebucht_von_name && ( Von: {detailBooking.gebucht_von_name} )} {canWrite && ( )} )} {/* ── Create / Edit dialog ── */} setDialogOpen(false)} maxWidth="sm" fullWidth > {editingBooking ? 'Buchung bearbeiten' : 'Neue Buchung'} {dialogError && ( {dialogError} )} Fahrzeug setForm((f) => ({ ...f, titel: e.target.value })) } /> setForm((f) => ({ ...f, beschreibung: e.target.value })) } /> setForm((f) => ({ ...f, beginn: e.target.value })) } InputLabelProps={{ shrink: true }} /> setForm((f) => ({ ...f, ende: e.target.value })) } InputLabelProps={{ shrink: true }} /> {/* Availability indicator */} {form.fahrzeugId && form.beginn && form.ende ? ( {availability === null ? ( Verfügbarkeit wird geprüft... ) : availability.available ? ( } label="Fahrzeug verfügbar" color="success" size="small" /> ) : availability.reason === 'out_of_service' ? ( } label={ availability.ausserDienstBis ? `Außer Dienst bis ${new Date(availability.ausserDienstBis).toLocaleDateString('de-DE')} (geschätzt)` : 'Fahrzeug ist außer Dienst' } color="error" size="small" /> ) : ( } label="Konflikt: bereits gebucht" color="error" size="small" /> )} ) : ( Wähle Fahrzeug und Zeitraum für Verfügbarkeitsprüfung )} Buchungsart {form.buchungsArt === 'extern' && ( <> setForm((f) => ({ ...f, kontaktPerson: e.target.value })) } /> setForm((f) => ({ ...f, kontaktTelefon: e.target.value, })) } /> )} {/* ── Cancel dialog ── */} setCancelId(null)} maxWidth="xs" fullWidth > Buchung stornieren setCancelGrund(e.target.value)} sx={{ mt: 1 }} helperText={`${cancelGrund.length}/1000 (min. 5 Zeichen)`} inputProps={{ maxLength: 1000 }} /> {/* ── iCal dialog ── */} setIcalOpen(false)} maxWidth="sm" fullWidth > Kalender abonnieren 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. { navigator.clipboard.writeText(icalUrl); notification.showSuccess('URL kopiert!'); }} > ), }} />
); } export default FahrzeugBuchungen;