Files
dashboard/frontend/src/pages/FahrzeugBuchungen.tsx

442 lines
17 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 { useState } from 'react';
import {
Box,
Container,
Typography,
Paper,
Card,
CardContent,
Button,
IconButton,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
CircularProgress,
Alert,
Chip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Stack,
Tooltip,
} from '@mui/material';
import {
Add,
IosShare,
ContentCopy,
Cancel,
Edit,
EventBusy,
Settings,
} from '@mui/icons-material';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { format, parseISO } from 'date-fns';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import GermanDateField from '../components/shared/GermanDateField';
import ServiceModePage from '../components/shared/ServiceModePage';
import { useAuth } from '../contexts/AuthContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext';
import { bookingApi, fetchVehicles, kategorieApi } from '../services/bookings';
import type {
BuchungsArt,
BuchungsKategorie,
} from '../types/booking.types';
import { BUCHUNGS_ART_LABELS, BUCHUNGS_ART_COLORS } from '../types/booking.types';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatBookingDate(beginn: string, ende: string, ganztaegig: boolean): string {
try {
const b = parseISO(beginn);
const e = parseISO(ende);
if (ganztaegig) {
return `${format(b, 'dd.MM.yyyy')} ${format(e, 'dd.MM.yyyy')} (Ganztägig)`;
}
return `${format(b, 'dd.MM.yyyy HH:mm')} ${format(e, 'dd.MM.yyyy HH:mm')}`;
} catch {
return `${beginn} ${ende}`;
}
}
function getCategoryColor(buchungsArt: BuchungsArt, kategorien: BuchungsKategorie[]): string {
const kat = kategorien.find((k) => k.bezeichnung.toLowerCase() === buchungsArt);
if (kat) return kat.farbe;
return BUCHUNGS_ART_COLORS[buchungsArt] || '#757575';
}
function getCategoryLabel(buchungsArt: BuchungsArt, kategorien: BuchungsKategorie[]): string {
const kat = kategorien.find((k) => k.bezeichnung.toLowerCase() === buchungsArt);
if (kat) return kat.bezeichnung;
return BUCHUNGS_ART_LABELS[buchungsArt] || buchungsArt;
}
// ---------------------------------------------------------------------------
// Main Page
// ---------------------------------------------------------------------------
function FahrzeugBuchungen() {
const { user } = useAuth();
const { hasPermission, isFeatureEnabled } = usePermissionContext();
const notification = useNotification();
const navigate = useNavigate();
const queryClient = useQueryClient();
const canCreate = hasPermission('fahrzeugbuchungen:create');
const canManage = hasPermission('fahrzeugbuchungen:manage');
// ── Filters ────────────────────────────────────────────────────────────────
const today = new Date();
const defaultFrom = format(today, 'yyyy-MM-dd');
const defaultTo = format(new Date(today.getTime() + 90 * 86400000), 'yyyy-MM-dd');
const [filterFrom, setFilterFrom] = useState(defaultFrom);
const [filterTo, setFilterTo] = useState(defaultTo);
const [filterVehicle, setFilterVehicle] = useState('');
const [filterKategorie, setFilterKategorie] = useState('');
// ── Data ───────────────────────────────────────────────────────────────────
const { data: vehicles = [] } = useQuery({
queryKey: ['vehicles'],
queryFn: fetchVehicles,
});
const { data: activeKategorien = [] } = useQuery({
queryKey: ['buchungskategorien'],
queryFn: kategorieApi.getActive,
});
const fromDate = filterFrom ? new Date(`${filterFrom}T00:00:00`) : new Date();
const toDate = filterTo ? new Date(`${filterTo}T23:59:59`) : new Date(Date.now() + 90 * 86400000);
const {
data: calendarData,
isLoading,
isError,
error: loadError,
} = useQuery({
queryKey: ['bookings-range', filterFrom, filterTo, filterVehicle],
queryFn: () =>
bookingApi.getCalendarRange(
fromDate,
toDate,
filterVehicle || undefined
),
});
const bookings = (calendarData?.bookings || [])
.filter((b) => !b.abgesagt)
.filter((b) => !filterKategorie || b.buchungs_art === filterKategorie)
.sort((a, b) => new Date(a.beginn).getTime() - new Date(b.beginn).getTime());
// ── Cancel ─────────────────────────────────────────────────────────────────
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);
setCancelGrund('');
queryClient.invalidateQueries({ queryKey: ['bookings-range'] });
} catch (e: unknown) {
const axiosErr = e as { response?: { data?: { message?: string } }; message?: string };
notification.showError(
axiosErr?.response?.data?.message || (e instanceof Error ? e.message : 'Fehler beim Stornieren')
);
} finally {
setCancelLoading(false);
}
};
// ── iCal ───────────────────────────────────────────────────────────────────
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) {
notification.showError(e instanceof Error ? e.message : 'Fehler beim Laden des Tokens');
}
};
// ── Render ─────────────────────────────────────────────────────────────────
if (!isFeatureEnabled('fahrzeugbuchungen')) {
return <ServiceModePage message="Fahrzeugbuchungen befinden sich aktuell im Wartungsmodus." />;
}
return (
<DashboardLayout>
<Container maxWidth="xl" sx={{ py: 3 }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h4" fontWeight={700}>
Fahrzeugbuchungen
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
{canManage && (
<Tooltip title="Einstellungen">
<IconButton onClick={() => navigate('/admin/settings?tab=1&subtab=fahrzeugbuchungen')}>
<Settings />
</IconButton>
</Tooltip>
)}
<Button startIcon={<IosShare />} onClick={handleIcalOpen} variant="outlined" size="small">
iCal abonnieren
</Button>
</Box>
</Box>
{/* ── Buchungen ─────────────────────────────────────────────── */}
<>
{/* Filters */}
<Paper sx={{ p: 2, mb: 2 }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems="center">
<GermanDateField
size="small"
label="Von"
value={filterFrom}
onChange={(iso) => setFilterFrom(iso)}
sx={{ minWidth: 160 }}
/>
<GermanDateField
size="small"
label="Bis"
value={filterTo}
onChange={(iso) => setFilterTo(iso)}
sx={{ minWidth: 160 }}
/>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Fahrzeug</InputLabel>
<Select
value={filterVehicle}
onChange={(e) => setFilterVehicle(e.target.value)}
label="Fahrzeug"
>
<MenuItem value="">Alle Fahrzeuge</MenuItem>
{vehicles.map((v) => (
<MenuItem key={v.id} value={v.id}>
{v.bezeichnung}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Kategorie</InputLabel>
<Select
value={filterKategorie}
onChange={(e) => setFilterKategorie(e.target.value)}
label="Kategorie"
>
<MenuItem value="">Alle Kategorien</MenuItem>
{activeKategorien.map((k) => (
<MenuItem key={k.id} value={k.bezeichnung.toLowerCase()}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: k.farbe,
}}
/>
{k.bezeichnung}
</Box>
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
</Paper>
{/* Loading */}
{isLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
)}
{/* Error */}
{isError && (
<Alert severity="error" sx={{ mb: 2 }}>
{loadError instanceof Error ? loadError.message : 'Fehler beim Laden'}
</Alert>
)}
{/* Bookings list */}
{!isLoading && !isError && bookings.length === 0 && (
<Paper sx={{ p: 4, textAlign: 'center' }}>
<EventBusy sx={{ fontSize: 48, color: 'text.secondary', mb: 1 }} />
<Typography variant="body1" color="text.secondary">
Keine Buchungen vorhanden
</Typography>
</Paper>
)}
{!isLoading &&
!isError &&
bookings.map((booking) => (
<Card key={booking.id} sx={{ mb: 1.5 }}>
<CardContent sx={{ pb: '12px !important' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle1" fontWeight={700}>
{booking.titel}
</Typography>
<Typography variant="body2" color="text.secondary">
{booking.fahrzeug_name}
{booking.fahrzeug_kennzeichen
? ` (${booking.fahrzeug_kennzeichen})`
: ''}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{formatBookingDate(booking.beginn, booking.ende, booking.ganztaegig)}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
<Chip
label={getCategoryLabel(booking.buchungs_art, activeKategorien)}
size="small"
sx={{
bgcolor: getCategoryColor(booking.buchungs_art, activeKategorien),
color: 'white',
}}
/>
{booking.gebucht_von_name && (
<Typography variant="caption" color="text.secondary">
von {booking.gebucht_von_name}
</Typography>
)}
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
{(canManage || booking.gebucht_von === user?.id) && (
<Tooltip title="Bearbeiten">
<IconButton
size="small"
onClick={() => navigate(`/fahrzeugbuchungen/${booking.id}`)}
>
<Edit fontSize="small" />
</IconButton>
</Tooltip>
)}
{(canManage || booking.gebucht_von === user?.id) && (
<Tooltip title="Stornieren">
<IconButton
size="small"
color="error"
onClick={() => {
setCancelId(booking.id);
setCancelGrund('');
}}
>
<Cancel fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
</Box>
</CardContent>
</Card>
))}
</>
{/* ── FAB ── */}
{canCreate && (
<ChatAwareFab
color="primary"
aria-label="Buchung erstellen"
onClick={() => navigate('/fahrzeugbuchungen/neu')}
>
<Add />
</ChatAwareFab>
)}
{/* ── 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;