Files
dashboard/frontend/src/pages/FahrzeugBuchungen.tsx
Matthias Hochmeister 507111e8e8 update
2026-03-26 12:12:18 +01:00

715 lines
28 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,
Tab,
Tabs,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Checkbox,
Stack,
Tooltip,
} from '@mui/material';
import {
Add,
IosShare,
ContentCopy,
Cancel,
Edit,
Delete,
Save,
Close,
EventBusy,
} from '@mui/icons-material';
import { useQuery, useMutation, 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');
const [tabIndex, setTabIndex] = useState(0);
// ── 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: kategorien = [] } = useQuery({
queryKey: ['buchungskategorien-all'],
queryFn: kategorieApi.getAll,
});
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');
}
};
// ── Einstellungen: Categories management ───────────────────────────────────
const [editRowId, setEditRowId] = useState<number | null>(null);
const [editRowData, setEditRowData] = useState<Partial<BuchungsKategorie>>({});
const [newKatDialog, setNewKatDialog] = useState(false);
const [newKatForm, setNewKatForm] = useState({ bezeichnung: '', farbe: '#1976d2' });
const createKatMutation = useMutation({
mutationFn: (data: Omit<BuchungsKategorie, 'id'>) => kategorieApi.create(data),
onSuccess: () => {
notification.showSuccess('Kategorie erstellt');
queryClient.invalidateQueries({ queryKey: ['buchungskategorien-all'] });
queryClient.invalidateQueries({ queryKey: ['buchungskategorien'] });
setNewKatDialog(false);
setNewKatForm({ bezeichnung: '', farbe: '#1976d2' });
},
onError: () => notification.showError('Fehler beim Erstellen'),
});
const updateKatMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<BuchungsKategorie> }) =>
kategorieApi.update(id, data),
onSuccess: () => {
notification.showSuccess('Kategorie aktualisiert');
queryClient.invalidateQueries({ queryKey: ['buchungskategorien-all'] });
queryClient.invalidateQueries({ queryKey: ['buchungskategorien'] });
setEditRowId(null);
},
onError: () => notification.showError('Fehler beim Aktualisieren'),
});
const deleteKatMutation = useMutation({
mutationFn: (id: number) => kategorieApi.delete(id),
onSuccess: () => {
notification.showSuccess('Kategorie deaktiviert');
queryClient.invalidateQueries({ queryKey: ['buchungskategorien-all'] });
queryClient.invalidateQueries({ queryKey: ['buchungskategorien'] });
},
onError: () => notification.showError('Fehler beim Löschen'),
});
// ── 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>
<Button startIcon={<IosShare />} onClick={handleIcalOpen} variant="outlined" size="small">
iCal abonnieren
</Button>
</Box>
{/* Tabs */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs value={tabIndex} onChange={(_, v) => setTabIndex(v)}>
<Tab label="Buchungen" />
{canManage && <Tab label="Einstellungen" />}
</Tabs>
</Box>
{/* ── Tab 0: Buchungen ─────────────────────────────────────────────── */}
{tabIndex === 0 && (
<>
{/* 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>
))}
</>
)}
{/* ── Tab 1: Einstellungen ─────────────────────────────────────────── */}
{tabIndex === 1 && canManage && (
<Paper sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Buchungskategorien</Typography>
<Button
startIcon={<Add />}
variant="contained"
size="small"
onClick={() => setNewKatDialog(true)}
>
Neue Kategorie
</Button>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Farbe</TableCell>
<TableCell>Sortierung</TableCell>
<TableCell>Aktiv</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{kategorien.map((kat) => {
const isEditing = editRowId === kat.id;
return (
<TableRow key={kat.id}>
<TableCell>
{isEditing ? (
<TextField
size="small"
value={editRowData.bezeichnung ?? kat.bezeichnung}
onChange={(e) =>
setEditRowData((d) => ({ ...d, bezeichnung: e.target.value }))
}
/>
) : (
kat.bezeichnung
)}
</TableCell>
<TableCell>
{isEditing ? (
<input
type="color"
value={editRowData.farbe ?? kat.farbe}
onChange={(e) =>
setEditRowData((d) => ({ ...d, farbe: e.target.value }))
}
style={{ width: 40, height: 28, border: 'none', cursor: 'pointer' }}
/>
) : (
<Box
sx={{
width: 24,
height: 24,
borderRadius: 1,
bgcolor: kat.farbe,
border: '1px solid',
borderColor: 'divider',
}}
/>
)}
</TableCell>
<TableCell>
{isEditing ? (
<TextField
size="small"
type="number"
value={editRowData.sort_order ?? kat.sort_order}
onChange={(e) =>
setEditRowData((d) => ({
...d,
sort_order: parseInt(e.target.value) || 0,
}))
}
sx={{ width: 80 }}
/>
) : (
kat.sort_order
)}
</TableCell>
<TableCell>
{isEditing ? (
<Checkbox
checked={editRowData.aktiv ?? kat.aktiv}
onChange={(e) =>
setEditRowData((d) => ({ ...d, aktiv: e.target.checked }))
}
/>
) : (
<Checkbox checked={kat.aktiv} disabled />
)}
</TableCell>
<TableCell align="right">
{isEditing ? (
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
<IconButton
size="small"
color="primary"
onClick={() =>
updateKatMutation.mutate({ id: kat.id, data: editRowData })
}
>
<Save fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => {
setEditRowId(null);
setEditRowData({});
}}
>
<Close fontSize="small" />
</IconButton>
</Stack>
) : (
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
<IconButton
size="small"
onClick={() => {
setEditRowId(kat.id);
setEditRowData({
bezeichnung: kat.bezeichnung,
farbe: kat.farbe,
sort_order: kat.sort_order,
aktiv: kat.aktiv,
});
}}
>
<Edit fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => deleteKatMutation.mutate(kat.id)}
>
<Delete fontSize="small" />
</IconButton>
</Stack>
)}
</TableCell>
</TableRow>
);
})}
{kategorien.length === 0 && (
<TableRow>
<TableCell colSpan={5} align="center">
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
Keine Kategorien vorhanden
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
)}
{/* ── FAB ── */}
{canCreate && tabIndex === 0 && (
<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>
{/* ── New category dialog ── */}
<Dialog
open={newKatDialog}
onClose={() => setNewKatDialog(false)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Neue Kategorie</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
fullWidth
size="small"
label="Bezeichnung"
required
value={newKatForm.bezeichnung}
onChange={(e) =>
setNewKatForm((f) => ({ ...f, bezeichnung: e.target.value }))
}
/>
<Box>
<Typography variant="body2" sx={{ mb: 0.5 }}>
Farbe
</Typography>
<input
type="color"
value={newKatForm.farbe}
onChange={(e) => setNewKatForm((f) => ({ ...f, farbe: e.target.value }))}
style={{ width: 60, height: 36, border: 'none', cursor: 'pointer' }}
/>
</Box>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setNewKatDialog(false)}>Abbrechen</Button>
<Button
variant="contained"
onClick={() =>
createKatMutation.mutate({
bezeichnung: newKatForm.bezeichnung,
farbe: newKatForm.farbe,
aktiv: true,
sort_order: kategorien.length,
})
}
disabled={!newKatForm.bezeichnung || createKatMutation.isPending}
>
Erstellen
</Button>
</DialogActions>
</Dialog>
</Container>
</DashboardLayout>
);
}
export default FahrzeugBuchungen;