969 lines
34 KiB
TypeScript
969 lines
34 KiB
TypeScript
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;
|