bug fix for atemschutz
This commit is contained in:
862
frontend/src/pages/FahrzeugBuchungen.tsx
Normal file
862
frontend/src/pages/FahrzeugBuchungen.tsx
Normal 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;
|
||||
439
frontend/src/pages/VeranstaltungKategorien.tsx
Normal file
439
frontend/src/pages/VeranstaltungKategorien.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1325
frontend/src/pages/Veranstaltungen.tsx
Normal file
1325
frontend/src/pages/Veranstaltungen.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user