Files
dashboard/frontend/src/pages/FahrzeugBuchungen.tsx
Matthias Hochmeister 215528a521 update
2026-03-16 14:41:08 +01:00

969 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback } 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<Date>(() =>
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<Fahrzeug[]>([]);
const [bookings, setBookings] = useState<FahrzeugBuchungListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<FahrzeugBuchungListItem | null>(null);
const [form, setForm] = useState<CreateBuchungInput>({ ...EMPTY_FORM });
const [dialogLoading, setDialogLoading] = useState(false);
const [dialogError, setDialogError] = useState<string | null>(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<string | null>(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<HTMLElement | null>(null);
const [detailBooking, setDetailBooking] =
useState<FahrzeugBuchungListItem | null>(null);
const handleBookingClick = (
e: React.MouseEvent<HTMLElement>,
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 (
<DashboardLayout>
<Container maxWidth="xl" sx={{ py: 3 }}>
{/* ── Header ── */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 3,
}}
>
<Typography variant="h4" fontWeight={700}>
Fahrzeugbuchungen
</Typography>
<Stack direction="row" spacing={1}>
<Button
startIcon={<IosShare />}
onClick={handleIcalOpen}
variant="outlined"
size="small"
>
Kalender
</Button>
{canCreate && (
<Button
startIcon={<Add />}
onClick={openCreateDialog}
variant="contained"
size="small"
>
Neue Buchung
</Button>
)}
</Stack>
</Box>
{/* ── Week navigation ── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<IconButton onClick={() => setCurrentWeekStart((d) => subWeeks(d, 1))}>
<ChevronLeft />
</IconButton>
<Typography
variant="h6"
sx={{ minWidth: 280, textAlign: 'center', userSelect: 'none' }}
>
{weekLabel}
</Typography>
<IconButton onClick={() => setCurrentWeekStart((d) => addWeeks(d, 1))}>
<ChevronRight />
</IconButton>
<Button
size="small"
onClick={() =>
setCurrentWeekStart(startOfWeek(new Date(), { weekStartsOn: 1 }))
}
startIcon={<Today />}
>
Heute
</Button>
</Box>
{/* ── Loading / error ── */}
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
)}
{!loading && error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{/* ── Timeline table ── */}
{!loading && !error && (
<TableContainer component={Paper} elevation={1}>
<Table size="small" sx={{ tableLayout: 'fixed' }}>
<TableHead>
<TableRow sx={{ bgcolor: (theme) => theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100' }}>
<TableCell sx={{ width: 160, fontWeight: 700 }}>
Fahrzeug
</TableCell>
{weekDays.map((day) => (
<TableCell
key={day.toISOString()}
align="center"
sx={{
fontWeight: isToday(day) ? 700 : 400,
color: isToday(day) ? 'primary.main' : 'text.primary',
bgcolor: isToday(day) ? (theme) => theme.palette.mode === 'dark' ? 'primary.900' : 'primary.50' : undefined,
}}
>
<Typography variant="caption" display="block">
{format(day, 'EEE', { locale: de })}
</Typography>
<Typography variant="body2" fontWeight="inherit">
{format(day, 'dd.MM.')}
</Typography>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{vehicles.map((vehicle) => (
<TableRow key={vehicle.id} hover>
<TableCell>
<Typography variant="body2" fontWeight={600}>
{vehicle.bezeichnung}
</Typography>
{vehicle.amtliches_kennzeichen && (
<Typography variant="caption" color="text.secondary">
{vehicle.amtliches_kennzeichen}
</Typography>
)}
</TableCell>
{weekDays.map((day) => {
const cellBookings = getBookingsForCell(vehicle.id, day);
const oos = isOutOfService(vehicle, day);
const isFree = cellBookings.length === 0 && !oos;
return (
<TableCell
key={day.toISOString()}
onClick={() =>
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 && (
<Tooltip title="Fahrzeug außer Dienst">
<Chip
icon={<Block fontSize="small" />}
label="Außer Dienst"
size="small"
color="error"
variant="outlined"
sx={{ fontSize: '0.6rem', height: 18, mb: 0.25, width: '100%' }}
/>
</Tooltip>
)}
{cellBookings.map((b) => (
<Tooltip
key={b.id}
title={`${b.titel} (${BUCHUNGS_ART_LABELS[b.buchungs_art]})`}
>
<Chip
label={
b.titel.length > 12
? b.titel.slice(0, 12) + '…'
: b.titel
}
size="small"
onClick={(e) => 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',
}}
/>
</Tooltip>
))}
</TableCell>
);
})}
</TableRow>
))}
{vehicles.length === 0 && (
<TableRow>
<TableCell colSpan={8} align="center">
<Typography
variant="body2"
color="text.secondary"
sx={{ py: 2 }}
>
Keine aktiven Fahrzeuge
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
)}
{/* ── Legend ── */}
{!loading && !error && (
<Box sx={{ display: 'flex', gap: 2, mt: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box
sx={{
width: 16,
height: 16,
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'success.900' : 'success.50',
border: '1px solid',
borderColor: (theme) => theme.palette.mode === 'dark' ? 'success.700' : 'success.300',
borderRadius: 0.5,
}}
/>
<Typography variant="caption">Frei</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box
sx={{
width: 16,
height: 16,
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'error.900' : 'error.50',
border: '1px solid',
borderColor: (theme) => theme.palette.mode === 'dark' ? 'error.700' : 'error.300',
borderRadius: 0.5,
}}
/>
<Typography variant="caption">Außer Dienst</Typography>
</Box>
{(Object.entries(BUCHUNGS_ART_LABELS) as [BuchungsArt, string][]).map(
([art, label]) => (
<Box key={art} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box
sx={{
width: 16,
height: 16,
bgcolor: BUCHUNGS_ART_COLORS[art],
borderRadius: 0.5,
}}
/>
<Typography variant="caption">{label}</Typography>
</Box>
)
)}
</Box>
)}
{/* ── FAB ── */}
{canCreate && (
<ChatAwareFab
color="primary"
aria-label="Buchung erstellen"
onClick={openCreateDialog}
>
<Add />
</ChatAwareFab>
)}
{/* ── Booking detail popover ── */}
<Popover
open={Boolean(detailAnchor)}
anchorEl={detailAnchor}
onClose={() => {
setDetailAnchor(null);
setDetailBooking(null);
}}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
>
{detailBooking && (
<Box sx={{ p: 2, maxWidth: 280 }}>
<Typography variant="subtitle2" fontWeight={700}>
{detailBooking.titel}
</Typography>
<Chip
label={BUCHUNGS_ART_LABELS[detailBooking.buchungs_art]}
size="small"
sx={{
bgcolor: BUCHUNGS_ART_COLORS[detailBooking.buchungs_art],
color: 'white',
mb: 1,
mt: 0.5,
}}
/>
<Typography variant="body2">
{format(parseISO(detailBooking.beginn), 'dd.MM.yyyy HH:mm')}
{' '}
{format(parseISO(detailBooking.ende), 'dd.MM.yyyy HH:mm')}
</Typography>
{detailBooking.gebucht_von_name && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
Von: {detailBooking.gebucht_von_name}
</Typography>
)}
{canWrite && (
<Box sx={{ mt: 1.5, display: 'flex', gap: 1 }}>
<Button
size="small"
startIcon={<Edit />}
onClick={handleOpenEdit}
>
Bearbeiten
</Button>
<Button
size="small"
color="error"
startIcon={<Cancel />}
onClick={handleOpenCancel}
>
Stornieren
</Button>
</Box>
)}
</Box>
)}
</Popover>
{/* ── Create / Edit dialog ── */}
<Dialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>
{editingBooking ? 'Buchung bearbeiten' : 'Neue Buchung'}
</DialogTitle>
<DialogContent>
{dialogError && (
<Alert severity="error" sx={{ mb: 2, mt: 1 }}>
{dialogError}
</Alert>
)}
<Stack spacing={2} sx={{ mt: 1 }}>
<FormControl fullWidth size="small" required>
<InputLabel>Fahrzeug</InputLabel>
<Select
value={form.fahrzeugId}
onChange={(e) =>
setForm((f) => ({ ...f, fahrzeugId: e.target.value }))
}
label="Fahrzeug"
>
{vehicles.map((v) => (
<MenuItem key={v.id} value={v.id}>
{v.bezeichnung}
{v.amtliches_kennzeichen ? ` (${v.amtliches_kennzeichen})` : ''}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
fullWidth
size="small"
label="Titel"
required
value={form.titel}
onChange={(e) =>
setForm((f) => ({ ...f, titel: e.target.value }))
}
/>
<TextField
fullWidth
size="small"
label="Beschreibung"
multiline
rows={2}
value={form.beschreibung || ''}
onChange={(e) =>
setForm((f) => ({ ...f, beschreibung: e.target.value }))
}
/>
<TextField
fullWidth
size="small"
label="Beginn"
type="datetime-local"
required
value={form.beginn}
onChange={(e) =>
setForm((f) => ({ ...f, beginn: e.target.value }))
}
InputLabelProps={{ shrink: true }}
/>
<TextField
fullWidth
size="small"
label="Ende"
type="datetime-local"
required
value={form.ende}
onChange={(e) =>
setForm((f) => ({ ...f, ende: e.target.value }))
}
InputLabelProps={{ shrink: true }}
/>
{/* Availability indicator */}
{form.fahrzeugId && form.beginn && form.ende ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{availability === null ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} />
<Typography variant="body2" color="text.secondary">
Verfügbarkeit wird geprüft...
</Typography>
</Box>
) : availability.available ? (
<Chip
icon={<CheckCircle />}
label="Fahrzeug verfügbar"
color="success"
size="small"
/>
) : availability.reason === 'out_of_service' ? (
<Chip
icon={<Block />}
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"
/>
) : (
<Chip
icon={<Warning />}
label="Konflikt: bereits gebucht"
color="error"
size="small"
/>
)}
</Box>
) : (
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
Wähle Fahrzeug und Zeitraum für Verfügbarkeitsprüfung
</Typography>
)}
<FormControl fullWidth size="small">
<InputLabel>Buchungsart</InputLabel>
<Select
value={form.buchungsArt}
disabled={!canChangeBuchungsArt}
onChange={(e) =>
setForm((f) => ({
...f,
buchungsArt: e.target.value as BuchungsArt,
}))
}
label="Buchungsart"
>
{(
Object.entries(BUCHUNGS_ART_LABELS) as [BuchungsArt, string][]
).map(([art, label]) => (
<MenuItem key={art} value={art}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
{form.buchungsArt === 'extern' && (
<>
<TextField
fullWidth
size="small"
label="Kontaktperson"
value={form.kontaktPerson || ''}
onChange={(e) =>
setForm((f) => ({ ...f, kontaktPerson: e.target.value }))
}
/>
<TextField
fullWidth
size="small"
label="Kontakttelefon"
value={form.kontaktTelefon || ''}
onChange={(e) =>
setForm((f) => ({
...f,
kontaktTelefon: e.target.value,
}))
}
/>
</>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
<Tooltip
title={
dialogLoading || (form.titel && form.fahrzeugId && form.beginn && form.ende)
? ''
: `Bitte fülle alle Pflichtfelder aus: ${[
!form.titel && 'Titel',
!form.fahrzeugId && 'Fahrzeug',
!form.beginn && 'Beginn',
!form.ende && 'Ende',
]
.filter(Boolean)
.join(', ')}`
}
disableHoverListener={!!(form.titel && form.fahrzeugId && form.beginn && form.ende)}
>
<span>
<Button
variant="contained"
onClick={handleSave}
disabled={
dialogLoading ||
!form.titel ||
!form.fahrzeugId ||
!form.beginn ||
!form.ende
}
>
{dialogLoading ? (
<CircularProgress size={20} />
) : editingBooking ? (
'Speichern'
) : (
'Buchen'
)}
</Button>
</span>
</Tooltip>
</DialogActions>
</Dialog>
{/* ── Cancel dialog ── */}
<Dialog
open={Boolean(cancelId)}
onClose={() => setCancelId(null)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Buchung stornieren</DialogTitle>
<DialogContent>
<TextField
fullWidth
multiline
rows={3}
label="Stornierungsgrund"
value={cancelGrund}
onChange={(e) => setCancelGrund(e.target.value)}
sx={{ mt: 1 }}
helperText={`${cancelGrund.length}/1000 (min. 5 Zeichen)`}
inputProps={{ maxLength: 1000 }}
/>
</DialogContent>
<DialogActions>
<Button
onClick={() => setCancelId(null)}
disabled={cancelLoading}
>
Abbrechen
</Button>
<Button
variant="contained"
color="error"
onClick={handleCancel}
disabled={cancelGrund.length < 5 || cancelLoading}
startIcon={
cancelLoading ? <CircularProgress size={16} /> : undefined
}
>
Stornieren
</Button>
</DialogActions>
</Dialog>
{/* ── iCal dialog ── */}
<Dialog
open={icalOpen}
onClose={() => setIcalOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Kalender abonnieren</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Abonniere den Fahrzeugbuchungskalender in deiner
Kalenderanwendung. Kopiere die URL und füge sie als neuen
Kalender (per URL) in Apple Kalender, Google Kalender oder
Outlook ein.
</Typography>
<TextField
fullWidth
value={icalUrl}
InputProps={{
readOnly: true,
endAdornment: (
<IconButton
onClick={() => {
navigator.clipboard.writeText(icalUrl);
notification.showSuccess('URL kopiert!');
}}
>
<ContentCopy />
</IconButton>
),
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setIcalOpen(false)}>Schließen</Button>
</DialogActions>
</Dialog>
</Container>
</DashboardLayout>
);
}
export default FahrzeugBuchungen;