bug fix for atemschutz

This commit is contained in:
Matthias Hochmeister
2026-03-01 19:19:12 +01:00
parent 2630224edd
commit 6495ca94d1
17 changed files with 5116 additions and 0 deletions

View File

@@ -0,0 +1,862 @@
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,
} from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout';
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,
} 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'];
// ---------------------------------------------------------------------------
// Main Page
// ---------------------------------------------------------------------------
function FahrzeugBuchungen() {
const { user } = useAuth();
const notification = useNotification();
const canWrite =
user?.groups?.some((g) => WRITE_GROUPS.includes(g)) ?? false;
// ── 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)
);
});
};
// ── 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<boolean | 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(({ available }) => {
if (!cancelled) setAvailability(available);
})
.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 (!canWrite) 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 }; message?: string };
if (axiosError?.response?.status === 409) {
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 msg = 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>
{canWrite && (
<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: '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) ? '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.name}
</Typography>
{vehicle.kennzeichen && (
<Typography variant="caption" color="text.secondary">
{vehicle.kennzeichen}
</Typography>
)}
</TableCell>
{weekDays.map((day) => {
const cellBookings = getBookingsForCell(vehicle.id, day);
const isFree = cellBookings.length === 0;
return (
<TableCell
key={day.toISOString()}
onClick={() =>
isFree ? handleCellClick(vehicle.id, day) : undefined
}
sx={{
bgcolor: isFree ? 'success.50' : undefined,
cursor: isFree && canWrite ? 'pointer' : 'default',
'&:hover': isFree && canWrite
? { bgcolor: 'success.100' }
: {},
p: 0.5,
verticalAlign: 'top',
}}
>
{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: 'success.50',
border: '1px solid',
borderColor: 'success.300',
borderRadius: 0.5,
}}
/>
<Typography variant="caption">Frei</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 ── */}
{canWrite && (
<Fab
color="primary"
aria-label="Buchung erstellen"
sx={{ position: 'fixed', bottom: 32, right: 32 }}
onClick={openCreateDialog}
>
<Add />
</Fab>
)}
{/* ── 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.name}
{v.kennzeichen ? ` (${v.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 !== null && (
<Chip
icon={availability ? <CheckCircle /> : <Warning />}
label={
availability
? 'Fahrzeug verfügbar'
: 'Konflikt: bereits gebucht'
}
color={availability ? 'success' : 'error'}
size="small"
sx={{ alignSelf: 'flex-start' }}
/>
)}
<FormControl fullWidth size="small">
<InputLabel>Buchungsart</InputLabel>
<Select
value={form.buchungsArt}
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>
<Button
variant="contained"
onClick={handleSave}
disabled={
dialogLoading ||
!form.titel ||
!form.fahrzeugId ||
!form.beginn ||
!form.ende
}
>
{dialogLoading ? (
<CircularProgress size={20} />
) : editingBooking ? (
'Speichern'
) : (
'Buchen'
)}
</Button>
</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;

View File

@@ -0,0 +1,439 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Container,
Typography,
Button,
IconButton,
CircularProgress,
Alert,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
TextField,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Tooltip,
Chip,
} from '@mui/material';
import {
Add,
Edit as EditIcon,
Delete as DeleteIcon,
Category as CategoryIcon,
} from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useAuth } from '../contexts/AuthContext';
import { useNotification } from '../contexts/NotificationContext';
import { eventsApi } from '../services/events';
import type { VeranstaltungKategorie } from '../types/events.types';
// ---------------------------------------------------------------------------
// Category Form Dialog
// ---------------------------------------------------------------------------
interface KategorieFormData {
name: string;
beschreibung: string;
farbe: string;
icon: string;
}
const EMPTY_FORM: KategorieFormData = {
name: '',
beschreibung: '',
farbe: '#1976d2',
icon: '',
};
interface KategorieDialogProps {
open: boolean;
onClose: () => void;
onSaved: () => void;
editing: VeranstaltungKategorie | null;
}
function KategorieDialog({ open, onClose, onSaved, editing }: KategorieDialogProps) {
const notification = useNotification();
const [loading, setLoading] = useState(false);
const [form, setForm] = useState<KategorieFormData>({ ...EMPTY_FORM });
useEffect(() => {
if (!open) return;
if (editing) {
setForm({
name: editing.name,
beschreibung: editing.beschreibung ?? '',
farbe: editing.farbe,
icon: editing.icon ?? '',
});
} else {
setForm({ ...EMPTY_FORM });
}
}, [open, editing]);
const handleChange = (field: keyof KategorieFormData, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
const handleSave = async () => {
if (!form.name.trim()) {
notification.showError('Name ist erforderlich');
return;
}
setLoading(true);
try {
const payload = {
name: form.name.trim(),
beschreibung: form.beschreibung.trim() || undefined,
farbe: form.farbe,
icon: form.icon.trim() || undefined,
};
if (editing) {
await eventsApi.updateKategorie(editing.id, payload);
notification.showSuccess('Kategorie aktualisiert');
} else {
await eventsApi.createKategorie(payload);
notification.showSuccess('Kategorie erstellt');
}
onSaved();
onClose();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Fehler beim Speichern';
notification.showError(msg);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>
{editing ? 'Kategorie bearbeiten' : 'Neue Kategorie'}
</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
label="Name"
value={form.name}
onChange={(e) => handleChange('name', e.target.value)}
required
fullWidth
autoFocus
/>
<TextField
label="Beschreibung"
value={form.beschreibung}
onChange={(e) => handleChange('beschreibung', e.target.value)}
multiline
rows={2}
fullWidth
/>
{/* Color picker */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<TextField
label="Farbe"
type="color"
value={form.farbe}
onChange={(e) => handleChange('farbe', e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{ width: 120 }}
inputProps={{ style: { height: 40, cursor: 'pointer', padding: '4px 8px' } }}
/>
<Box
sx={{
width: 40,
height: 40,
borderRadius: 1,
bgcolor: form.farbe,
border: '1px solid',
borderColor: 'divider',
flexShrink: 0,
}}
/>
<Typography variant="caption" color="text.secondary" sx={{ fontFamily: 'monospace' }}>
{form.farbe.toUpperCase()}
</Typography>
</Box>
<TextField
label="Icon (MUI Icon Name, optional)"
value={form.icon}
onChange={(e) => handleChange('icon', e.target.value)}
fullWidth
placeholder="z.B. EmojiEvents"
helperText="Name eines MUI Material Icons"
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Abbrechen</Button>
<Button variant="contained" onClick={handleSave} disabled={loading}>
{loading ? <CircularProgress size={20} /> : editing ? 'Speichern' : 'Erstellen'}
</Button>
</DialogActions>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Delete Confirm Dialog
// ---------------------------------------------------------------------------
interface DeleteDialogProps {
open: boolean;
kategorie: VeranstaltungKategorie | null;
onClose: () => void;
onDeleted: () => void;
}
function DeleteDialog({ open, kategorie, onClose, onDeleted }: DeleteDialogProps) {
const notification = useNotification();
const [loading, setLoading] = useState(false);
const handleDelete = async () => {
if (!kategorie) return;
setLoading(true);
try {
await eventsApi.deleteKategorie(kategorie.id);
notification.showSuccess('Kategorie gelöscht');
onDeleted();
onClose();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Fehler beim Löschen';
notification.showError(msg);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>Kategorie löschen</DialogTitle>
<DialogContent>
<DialogContentText>
Soll die Kategorie <strong>{kategorie?.name}</strong> wirklich gelöscht werden?
Bestehende Veranstaltungen behalten ihre Farbzuweisung.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Abbrechen</Button>
<Button variant="contained" color="error" onClick={handleDelete} disabled={loading}>
{loading ? <CircularProgress size={20} /> : 'Löschen'}
</Button>
</DialogActions>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Main Page
// ---------------------------------------------------------------------------
export default function VeranstaltungKategorien() {
const { user } = useAuth();
const canManage =
user?.groups?.some((g) => ['dashboard_admin', 'dashboard_moderator'].includes(g)) ?? false;
const [kategorien, setKategorien] = useState<VeranstaltungKategorie[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Form dialog
const [formOpen, setFormOpen] = useState(false);
const [editingKat, setEditingKat] = useState<VeranstaltungKategorie | null>(null);
// Delete dialog
const [deleteTarget, setDeleteTarget] = useState<VeranstaltungKategorie | null>(null);
const loadKategorien = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await eventsApi.getKategorien();
setKategorien(data);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Kategorien';
setError(msg);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadKategorien();
}, [loadKategorien]);
return (
<DashboardLayout>
<Container maxWidth="lg" sx={{ py: 3 }}>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: 1,
mb: 3,
}}
>
<CategoryIcon color="primary" />
<Typography variant="h5" sx={{ flexGrow: 1, fontWeight: 700 }}>
Veranstaltungskategorien
</Typography>
{canManage && (
<Button
startIcon={<Add />}
variant="contained"
onClick={() => { setEditingKat(null); setFormOpen(true); }}
>
Neue Kategorie
</Button>
)}
</Box>
{/* Error */}
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* Loading */}
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
)}
{/* Table */}
{!loading && !error && (
<TableContainer component={Paper} elevation={1}>
<Table>
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 700 }}>Farbe</TableCell>
<TableCell sx={{ fontWeight: 700 }}>Name</TableCell>
<TableCell sx={{ fontWeight: 700 }}>Beschreibung</TableCell>
<TableCell sx={{ fontWeight: 700 }}>Icon</TableCell>
{canManage && <TableCell align="right" sx={{ fontWeight: 700 }}>Aktionen</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{kategorien.length === 0 ? (
<TableRow>
<TableCell colSpan={canManage ? 5 : 4} align="center" sx={{ py: 4 }}>
<Typography variant="body2" color="text.secondary">
Noch keine Kategorien vorhanden.
</Typography>
</TableCell>
</TableRow>
) : (
kategorien.map((kat) => (
<TableRow
key={kat.id}
hover
sx={{ '&:last-child td': { border: 0 } }}
>
{/* Color swatch */}
<TableCell>
<Tooltip title={kat.farbe.toUpperCase()}>
<Box
sx={{
width: 28,
height: 28,
borderRadius: 1,
bgcolor: kat.farbe,
border: '1px solid',
borderColor: 'divider',
}}
/>
</Tooltip>
</TableCell>
{/* Name */}
<TableCell>
<Chip
label={kat.name}
size="small"
sx={{
bgcolor: kat.farbe,
color: 'white',
fontWeight: 600,
}}
/>
</TableCell>
{/* Description */}
<TableCell>
<Typography variant="body2" color="text.secondary">
{kat.beschreibung ?? '—'}
</Typography>
</TableCell>
{/* Icon */}
<TableCell>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
{kat.icon ?? '—'}
</Typography>
</TableCell>
{/* Actions */}
{canManage && (
<TableCell align="right">
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
<Tooltip title="Bearbeiten">
<IconButton
size="small"
onClick={() => { setEditingKat(kat); setFormOpen(true); }}
>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Löschen">
<IconButton
size="small"
color="error"
onClick={() => setDeleteTarget(kat)}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
)}
{/* Create/Edit Dialog */}
<KategorieDialog
open={formOpen}
onClose={() => { setFormOpen(false); setEditingKat(null); }}
onSaved={loadKategorien}
editing={editingKat}
/>
{/* Delete Confirm Dialog */}
<DeleteDialog
open={Boolean(deleteTarget)}
kategorie={deleteTarget}
onClose={() => setDeleteTarget(null)}
onDeleted={loadKategorien}
/>
</Container>
</DashboardLayout>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
import { api } from './api';
import type {
FahrzeugBuchungListItem,
FahrzeugBuchung,
Fahrzeug,
CreateBuchungInput,
} from '../types/booking.types';
// ---------------------------------------------------------------------------
// Response shapes from the backend
// ---------------------------------------------------------------------------
interface ApiResponse<T> {
success: boolean;
data: T;
}
// ---------------------------------------------------------------------------
// Booking API service
// ---------------------------------------------------------------------------
export const bookingApi = {
// -------------------------------------------------------------------------
// Calendar / listing
// -------------------------------------------------------------------------
getCalendarRange(from: Date, to: Date, fahrzeugId?: string): Promise<FahrzeugBuchungListItem[]> {
return api
.get<ApiResponse<FahrzeugBuchungListItem[]>>('/api/bookings/calendar', {
params: {
from: from.toISOString(),
to: to.toISOString(),
...(fahrzeugId ? { fahrzeugId } : {}),
},
})
.then((r) => r.data.data);
},
getUpcoming(limit = 20): Promise<FahrzeugBuchungListItem[]> {
return api
.get<ApiResponse<FahrzeugBuchungListItem[]>>('/api/bookings/upcoming', {
params: { limit },
})
.then((r) => r.data.data);
},
// -------------------------------------------------------------------------
// Availability check
// -------------------------------------------------------------------------
checkAvailability(
fahrzeugId: string,
from: Date,
to: Date
): Promise<{ available: boolean }> {
return api
.get<ApiResponse<{ available: boolean }>>('/api/bookings/availability', {
params: {
fahrzeugId,
from: from.toISOString(),
to: to.toISOString(),
},
})
.then((r) => r.data.data);
},
// -------------------------------------------------------------------------
// Single booking
// -------------------------------------------------------------------------
getById(id: string): Promise<FahrzeugBuchung> {
return api
.get<ApiResponse<FahrzeugBuchung>>(`/api/bookings/${id}`)
.then((r) => r.data.data);
},
// -------------------------------------------------------------------------
// CRUD
// -------------------------------------------------------------------------
create(data: CreateBuchungInput): Promise<FahrzeugBuchung> {
return api
.post<ApiResponse<FahrzeugBuchung>>('/api/bookings', data)
.then((r) => r.data.data);
},
update(id: string, data: Partial<CreateBuchungInput>): Promise<FahrzeugBuchung> {
return api
.patch<ApiResponse<FahrzeugBuchung>>(`/api/bookings/${id}`, data)
.then((r) => r.data.data);
},
cancel(id: string, abgesagt_grund: string): Promise<void> {
return api
.delete(`/api/bookings/${id}`, { data: { abgesagt_grund } })
.then(() => undefined);
},
// -------------------------------------------------------------------------
// iCal
// -------------------------------------------------------------------------
getCalendarToken(): Promise<{ token: string; subscribeUrl: string }> {
return api
.get<ApiResponse<{ token: string; subscribeUrl: string }>>(
'/api/bookings/calendar-token'
)
.then((r) => r.data.data);
},
};
// ---------------------------------------------------------------------------
// Vehicle helper (shared with booking page)
// ---------------------------------------------------------------------------
export function fetchVehicles(): Promise<Fahrzeug[]> {
return api
.get<ApiResponse<Fahrzeug[]>>('/api/vehicles')
.then((r) => r.data.data.filter((v: Fahrzeug) => !v.archived_at));
}

View File

@@ -0,0 +1,143 @@
import { api } from './api';
import type {
VeranstaltungKategorie,
VeranstaltungListItem,
Veranstaltung,
GroupInfo,
CreateVeranstaltungInput,
} from '../types/events.types';
// ---------------------------------------------------------------------------
// Response shapes from the backend
// ---------------------------------------------------------------------------
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
// ---------------------------------------------------------------------------
// Events API service
// ---------------------------------------------------------------------------
export const eventsApi = {
// -------------------------------------------------------------------------
// Kategorien (Categories)
// -------------------------------------------------------------------------
/** List all event categories */
getKategorien(): Promise<VeranstaltungKategorie[]> {
return api
.get<ApiResponse<VeranstaltungKategorie[]>>('/api/events/kategorien')
.then((r) => r.data.data);
},
/** Create a new event category */
createKategorie(data: {
name: string;
beschreibung?: string;
farbe?: string;
icon?: string;
}): Promise<VeranstaltungKategorie> {
return api
.post<ApiResponse<VeranstaltungKategorie>>('/api/events/kategorien', data)
.then((r) => r.data.data);
},
/** Update an existing event category */
updateKategorie(
id: string,
data: Partial<{ name: string; beschreibung?: string; farbe?: string; icon?: string }>
): Promise<VeranstaltungKategorie> {
return api
.patch<ApiResponse<VeranstaltungKategorie>>(`/api/events/kategorien/${id}`, data)
.then((r) => r.data.data);
},
/** Delete an event category */
deleteKategorie(id: string): Promise<void> {
return api
.delete(`/api/events/kategorien/${id}`)
.then(() => undefined);
},
// -------------------------------------------------------------------------
// Groups
// -------------------------------------------------------------------------
/** List all available target groups */
getGroups(): Promise<GroupInfo[]> {
return api
.get<ApiResponse<GroupInfo[]>>('/api/events/groups')
.then((r) => r.data.data);
},
// -------------------------------------------------------------------------
// Event listing
// -------------------------------------------------------------------------
/** Events in a date range for the month calendar view */
getCalendarRange(from: Date, to: Date): Promise<VeranstaltungListItem[]> {
return api
.get<ApiResponse<VeranstaltungListItem[]>>('/api/events/calendar', {
params: {
from: from.toISOString(),
to: to.toISOString(),
},
})
.then((r) => r.data.data);
},
/** Upcoming events (dashboard widget, list view) */
getUpcoming(limit = 10): Promise<VeranstaltungListItem[]> {
return api
.get<ApiResponse<VeranstaltungListItem[]>>('/api/events/upcoming', {
params: { limit },
})
.then((r) => r.data.data);
},
/** Full event detail */
getById(id: string): Promise<Veranstaltung> {
return api
.get<ApiResponse<Veranstaltung>>(`/api/events/${id}`)
.then((r) => r.data.data);
},
// -------------------------------------------------------------------------
// CRUD
// -------------------------------------------------------------------------
/** Create a new event */
createEvent(data: CreateVeranstaltungInput): Promise<Veranstaltung> {
return api
.post<ApiResponse<Veranstaltung>>('/api/events', data)
.then((r) => r.data.data);
},
/** Update an existing event */
updateEvent(id: string, data: Partial<CreateVeranstaltungInput>): Promise<Veranstaltung> {
return api
.patch<ApiResponse<Veranstaltung>>(`/api/events/${id}`, data)
.then((r) => r.data.data);
},
/** Cancel (soft-delete) an event with a reason */
cancelEvent(id: string, abgesagt_grund: string): Promise<void> {
return api
.delete(`/api/events/${id}`, { data: { abgesagt_grund } })
.then(() => undefined);
},
// -------------------------------------------------------------------------
// iCal
// -------------------------------------------------------------------------
/** Get the user's personal calendar subscribe URL */
getCalendarToken(): Promise<{ token: string; subscribeUrl: string }> {
return api
.get<ApiResponse<{ token: string; subscribeUrl: string }>>(
'/api/events/calendar-token'
)
.then((r) => r.data.data);
},
};

View File

@@ -0,0 +1,60 @@
export type BuchungsArt = 'intern' | 'extern' | 'wartung' | 'reservierung' | 'sonstiges';
export const BUCHUNGS_ART_LABELS: Record<BuchungsArt, string> = {
intern: 'Intern',
extern: 'Extern',
wartung: 'Wartung/Service',
reservierung: 'Reservierung',
sonstiges: 'Sonstiges',
};
export const BUCHUNGS_ART_COLORS: Record<BuchungsArt, string> = {
intern: '#1976d2',
extern: '#e65100',
wartung: '#616161',
reservierung: '#7b1fa2',
sonstiges: '#00695c',
};
export interface FahrzeugBuchungListItem {
id: string;
fahrzeug_id: string;
fahrzeug_name: string;
fahrzeug_kennzeichen?: string | null;
titel: string;
buchungs_art: BuchungsArt;
beginn: string; // ISO
ende: string; // ISO
abgesagt: boolean;
gebucht_von_name?: string | null;
}
export interface FahrzeugBuchung extends FahrzeugBuchungListItem {
beschreibung?: string | null;
kontakt_person?: string | null;
kontakt_telefon?: string | null;
gebucht_von: string;
abgesagt_grund?: string | null;
erstellt_am: string;
aktualisiert_am: string;
}
export interface Fahrzeug {
id: string;
name: string;
kennzeichen?: string | null;
typ: string;
is_active: boolean;
archived_at?: string | null;
}
export interface CreateBuchungInput {
fahrzeugId: string;
titel: string;
beschreibung?: string | null;
beginn: string; // ISO
ende: string; // ISO
buchungsArt: BuchungsArt;
kontaktPerson?: string | null;
kontaktTelefon?: string | null;
}

View File

@@ -0,0 +1,64 @@
// ---------------------------------------------------------------------------
// Frontend events types — mirrors backend events model
// ---------------------------------------------------------------------------
export interface VeranstaltungKategorie {
id: string;
name: string;
beschreibung?: string | null;
farbe: string; // hex color e.g. '#1976d2'
icon?: string | null; // MUI icon name
erstellt_am: string;
aktualisiert_am: string;
}
export interface VeranstaltungListItem {
id: string;
titel: string;
beschreibung?: string | null;
ort?: string | null;
kategorie_id?: string | null;
kategorie_name?: string | null;
kategorie_farbe?: string | null;
kategorie_icon?: string | null;
datum_von: string; // ISO string
datum_bis: string; // ISO string
ganztaegig: boolean;
zielgruppen: string[];
alle_gruppen: boolean;
abgesagt: boolean;
anmeldung_erforderlich: boolean;
}
export interface Veranstaltung extends VeranstaltungListItem {
ort_url?: string | null;
max_teilnehmer?: number | null;
anmeldung_bis?: string | null;
erstellt_von: string;
erstellt_von_name?: string | null;
abgesagt_grund?: string | null;
abgesagt_am?: string | null;
erstellt_am: string;
aktualisiert_am: string;
}
export interface GroupInfo {
id: string;
label: string;
}
export interface CreateVeranstaltungInput {
titel: string;
beschreibung?: string | null;
ort?: string | null;
ort_url?: string | null;
kategorie_id?: string | null;
datum_von: string; // ISO
datum_bis: string; // ISO
ganztaegig: boolean;
zielgruppen: string[];
alle_gruppen: boolean;
max_teilnehmer?: number | null;
anmeldung_erforderlich: boolean;
anmeldung_bis?: string | null;
}