442 lines
17 KiB
TypeScript
442 lines
17 KiB
TypeScript
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;
|