calendar and vehicle booking rework

This commit is contained in:
Matthias Hochmeister
2026-03-26 08:47:38 +01:00
parent 21bbe57d6f
commit 884397b520
5 changed files with 586 additions and 291 deletions

View File

@@ -2,6 +2,7 @@ import { Request, Response } from 'express';
import { ZodError } from 'zod'; import { ZodError } from 'zod';
import bookingService from '../services/booking.service'; import bookingService from '../services/booking.service';
import vehicleService from '../services/vehicle.service'; import vehicleService from '../services/vehicle.service';
import pool from '../config/database';
import { permissionService } from '../services/permission.service'; import { permissionService } from '../services/permission.service';
import { import {
CreateBuchungSchema, CreateBuchungSchema,
@@ -43,6 +44,22 @@ function handleConflictError(res: Response, err: Error): boolean {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
class BookingController { class BookingController {
/**
* GET /api/bookings/vehicles
* Lightweight vehicle list for the booking form (no fahrzeuge:view needed).
*/
async getVehiclesForBooking(_req: Request, res: Response): Promise<void> {
try {
const result = await pool.query(
'SELECT id, bezeichnung, amtliches_kennzeichen FROM fahrzeuge ORDER BY bezeichnung'
);
res.json({ success: true, data: result.rows });
} catch (err) {
logger.error('Failed to fetch vehicles for booking', err);
res.status(500).json({ success: false, message: 'Fehler beim Laden der Fahrzeuge' });
}
}
/** /**
* GET /api/bookings/calendar?from=&to=&fahrzeugId= * GET /api/bookings/calendar?from=&to=&fahrzeugId=
* Returns all non-cancelled bookings overlapping the given date range. * Returns all non-cancelled bookings overlapping the given date range.

View File

@@ -11,6 +11,7 @@ router.get('/calendar.ics', optionalAuth, bookingController.getIcalExport.bind(b
// ── Read-only (all authenticated users) ────────────────────────────────────── // ── Read-only (all authenticated users) ──────────────────────────────────────
router.get('/vehicles', authenticate, bookingController.getVehiclesForBooking.bind(bookingController));
router.get('/calendar', authenticate, bookingController.getCalendarRange.bind(bookingController)); router.get('/calendar', authenticate, bookingController.getCalendarRange.bind(bookingController));
router.get('/upcoming', authenticate, bookingController.getUpcoming.bind(bookingController)); router.get('/upcoming', authenticate, bookingController.getUpcoming.bind(bookingController));
router.get('/availability', authenticate, bookingController.checkAvailability.bind(bookingController)); router.get('/availability', authenticate, bookingController.checkAvailability.bind(bookingController));

View File

@@ -2,7 +2,6 @@ import { useState, useEffect } from 'react';
import { import {
Box, Box,
Typography, Typography,
Paper,
Button, Button,
TextField, TextField,
Select, Select,
@@ -16,6 +15,11 @@ import {
IconButton, IconButton,
Chip, Chip,
Stack, Stack,
Card,
CardContent,
CardHeader,
Container,
Grid,
} from '@mui/material'; } from '@mui/material';
import { import {
ArrowBack, ArrowBack,
@@ -237,7 +241,8 @@ function BookingFormPage() {
{!isFeatureEnabled('fahrzeugbuchungen') ? ( {!isFeatureEnabled('fahrzeugbuchungen') ? (
<ServiceModePage message="Fahrzeugbuchungen befinden sich aktuell im Wartungsmodus." /> <ServiceModePage message="Fahrzeugbuchungen befinden sich aktuell im Wartungsmodus." />
) : ( ) : (
<Box sx={{ p: 3 }}> <Container maxWidth="md" sx={{ py: 3 }}>
{/* Page header */}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}> <Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
<IconButton onClick={() => navigate('/fahrzeugbuchungen')}> <IconButton onClick={() => navigate('/fahrzeugbuchungen')}>
<ArrowBack /> <ArrowBack />
@@ -247,13 +252,17 @@ function BookingFormPage() {
</Typography> </Typography>
</Box> </Box>
<Paper sx={{ p: 3, maxWidth: 600 }}>
{error && ( {error && (
<Alert severity="error" sx={{ mb: 2 }}> <Alert severity="error" sx={{ mb: 2 }}>
{error} {error}
</Alert> </Alert>
)} )}
<Stack spacing={3}>
{/* Card 1: Fahrzeug & Zeitraum */}
<Card variant="outlined">
<CardHeader title="Fahrzeug & Zeitraum" titleTypographyProps={{ variant: 'subtitle1', fontWeight: 600 }} />
<CardContent>
<Stack spacing={2}> <Stack spacing={2}>
<FormControl fullWidth size="small" required> <FormControl fullWidth size="small" required>
<InputLabel>Fahrzeug</InputLabel> <InputLabel>Fahrzeug</InputLabel>
@@ -275,28 +284,56 @@ function BookingFormPage() {
</Select> </Select>
</FormControl> </FormControl>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<TextField <TextField
fullWidth fullWidth
size="small" size="small"
label="Titel" label="Beginn"
type={form.ganztaegig ? 'date' : 'datetime-local'}
required required
value={form.titel} value={
onChange={(e) => form.ganztaegig
setForm((f) => ({ ...f, titel: e.target.value })) ? form.beginn?.split('T')[0] || ''
: form.beginn
} }
onChange={(e) => {
if (form.ganztaegig) {
setForm((f) => ({
...f,
beginn: `${e.target.value}T00:00`,
}));
} else {
setForm((f) => ({ ...f, beginn: e.target.value }));
}
}}
InputLabelProps={{ shrink: true }}
/> />
</Grid>
<Grid item xs={12} sm={6}>
<TextField <TextField
fullWidth fullWidth
size="small" size="small"
label="Beschreibung" label="Ende"
multiline type={form.ganztaegig ? 'date' : 'datetime-local'}
rows={2} required
value={form.beschreibung || ''} value={
onChange={(e) => form.ganztaegig ? form.ende?.split('T')[0] || '' : form.ende
setForm((f) => ({ ...f, beschreibung: e.target.value }))
} }
onChange={(e) => {
if (form.ganztaegig) {
setForm((f) => ({
...f,
ende: `${e.target.value}T23:59`,
}));
} else {
setForm((f) => ({ ...f, ende: e.target.value }));
}
}}
InputLabelProps={{ shrink: true }}
/> />
</Grid>
</Grid>
<FormControlLabel <FormControlLabel
control={ control={
@@ -324,52 +361,6 @@ function BookingFormPage() {
label="Ganztägig" label="Ganztägig"
/> />
<TextField
fullWidth
size="small"
label="Beginn"
type={form.ganztaegig ? 'date' : 'datetime-local'}
required
value={
form.ganztaegig
? form.beginn?.split('T')[0] || ''
: form.beginn
}
onChange={(e) => {
if (form.ganztaegig) {
setForm((f) => ({
...f,
beginn: `${e.target.value}T00:00`,
}));
} else {
setForm((f) => ({ ...f, beginn: e.target.value }));
}
}}
InputLabelProps={{ shrink: true }}
/>
<TextField
fullWidth
size="small"
label="Ende"
type={form.ganztaegig ? 'date' : 'datetime-local'}
required
value={
form.ganztaegig ? form.ende?.split('T')[0] || '' : form.ende
}
onChange={(e) => {
if (form.ganztaegig) {
setForm((f) => ({
...f,
ende: `${e.target.value}T23:59`,
}));
} else {
setForm((f) => ({ ...f, ende: e.target.value }));
}
}}
InputLabelProps={{ shrink: true }}
/>
{/* Availability indicator */} {/* Availability indicator */}
{form.fahrzeugId && form.beginn && form.ende ? ( {form.fahrzeugId && form.beginn && form.ende ? (
!formDatesValid ? ( !formDatesValid ? (
@@ -448,6 +439,37 @@ function BookingFormPage() {
Wähle Fahrzeug und Zeitraum für Verfügbarkeitsprüfung Wähle Fahrzeug und Zeitraum für Verfügbarkeitsprüfung
</Typography> </Typography>
)} )}
</Stack>
</CardContent>
</Card>
{/* Card 2: Details */}
<Card variant="outlined">
<CardHeader title="Details" titleTypographyProps={{ variant: 'subtitle1', fontWeight: 600 }} />
<CardContent>
<Stack spacing={2}>
<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 }))
}
/>
<FormControl fullWidth size="small"> <FormControl fullWidth size="small">
<InputLabel>Kategorie</InputLabel> <InputLabel>Kategorie</InputLabel>
@@ -519,8 +541,11 @@ function BookingFormPage() {
</> </>
)} )}
</Stack> </Stack>
</CardContent>
</Card>
<Box sx={{ mt: 3, display: 'flex', gap: 2 }}> {/* Actions */}
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
<Button <Button
variant="outlined" variant="outlined"
onClick={() => navigate('/fahrzeugbuchungen')} onClick={() => navigate('/fahrzeugbuchungen')}
@@ -541,8 +566,8 @@ function BookingFormPage() {
{saving ? <CircularProgress size={20} /> : 'Speichern'} {saving ? <CircularProgress size={20} /> : 'Speichern'}
</Button> </Button>
</Box> </Box>
</Paper> </Stack>
</Box> </Container>
)} )}
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -24,16 +24,20 @@ import {
MenuItem, MenuItem,
Paper, Paper,
Popover, Popover,
Radio,
RadioGroup,
Select, Select,
Skeleton, Skeleton,
Stack, Stack,
Switch, Switch,
Tab,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableContainer, TableContainer,
TableHead, TableHead,
TableRow, TableRow,
Tabs,
TextField, TextField,
Tooltip, Tooltip,
Typography, Typography,
@@ -55,14 +59,14 @@ import {
HelpOutline as UnknownIcon, HelpOutline as UnknownIcon,
IosShare, IosShare,
PictureAsPdf as PdfIcon, PictureAsPdf as PdfIcon,
Settings as SettingsIcon,
Star as StarIcon, Star as StarIcon,
Today as TodayIcon, Today as TodayIcon,
Tune,
ViewList as ListViewIcon, ViewList as ListViewIcon,
ViewDay as ViewDayIcon, ViewDay as ViewDayIcon,
ViewWeek as ViewWeekIcon, ViewWeek as ViewWeekIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import ServiceModePage from '../components/shared/ServiceModePage'; import ServiceModePage from '../components/shared/ServiceModePage';
import ChatAwareFab from '../components/shared/ChatAwareFab'; import ChatAwareFab from '../components/shared/ChatAwareFab';
@@ -1620,6 +1624,155 @@ function VeranstaltungFormDialog({
); );
} }
// ──────────────────────────────────────────────────────────────────────────────
// Settings Tab (Kategorien CRUD)
// ──────────────────────────────────────────────────────────────────────────────
interface SettingsTabProps {
kategorien: VeranstaltungKategorie[];
onKategorienChange: (k: VeranstaltungKategorie[]) => void;
}
function SettingsTab({ kategorien, onKategorienChange }: SettingsTabProps) {
const notification = useNotification();
const [editingKat, setEditingKat] = useState<VeranstaltungKategorie | null>(null);
const [newKatOpen, setNewKatOpen] = useState(false);
const [newKatForm, setNewKatForm] = useState({ name: '', farbe: '#1976d2', beschreibung: '' });
const [saving, setSaving] = useState(false);
const reload = async () => {
const kat = await eventsApi.getKategorien();
onKategorienChange(kat);
};
const handleCreate = async () => {
if (!newKatForm.name.trim()) return;
setSaving(true);
try {
await eventsApi.createKategorie({ name: newKatForm.name.trim(), farbe: newKatForm.farbe, beschreibung: newKatForm.beschreibung || undefined });
notification.showSuccess('Kategorie erstellt');
setNewKatOpen(false);
setNewKatForm({ name: '', farbe: '#1976d2', beschreibung: '' });
await reload();
} catch { notification.showError('Fehler beim Erstellen'); }
finally { setSaving(false); }
};
const handleUpdate = async () => {
if (!editingKat) return;
setSaving(true);
try {
await eventsApi.updateKategorie(editingKat.id, { name: editingKat.name, farbe: editingKat.farbe, beschreibung: editingKat.beschreibung ?? undefined });
notification.showSuccess('Kategorie gespeichert');
setEditingKat(null);
await reload();
} catch { notification.showError('Fehler beim Speichern'); }
finally { setSaving(false); }
};
const handleDelete = async (id: string) => {
try {
await eventsApi.deleteKategorie(id);
notification.showSuccess('Kategorie gelöscht');
await reload();
} catch { notification.showError('Fehler beim Löschen'); }
};
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" sx={{ flexGrow: 1 }}>Veranstaltungskategorien</Typography>
<Button startIcon={<Add />} variant="contained" size="small" onClick={() => setNewKatOpen(true)}>
Neue Kategorie
</Button>
</Box>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Farbe</TableCell>
<TableCell>Name</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{kategorien.map((k) => (
<TableRow key={k.id}>
<TableCell>
<Box sx={{ width: 24, height: 24, borderRadius: '50%', bgcolor: k.farbe, border: '1px solid', borderColor: 'divider' }} />
</TableCell>
<TableCell>{k.name}</TableCell>
<TableCell>{k.beschreibung ?? '—'}</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => setEditingKat({ ...k })}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="error" onClick={() => handleDelete(k.id)}>
<DeleteForeverIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
{kategorien.length === 0 && (
<TableRow>
<TableCell colSpan={4} sx={{ textAlign: 'center', py: 3, color: 'text.secondary' }}>
Noch keine Kategorien vorhanden
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{/* Edit dialog */}
<Dialog open={Boolean(editingKat)} onClose={() => setEditingKat(null)} maxWidth="xs" fullWidth>
<DialogTitle>Kategorie bearbeiten</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Name" value={editingKat?.name ?? ''} onChange={(e) => setEditingKat((k) => k ? { ...k, name: e.target.value } : k)} fullWidth required />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="body2">Farbe</Typography>
<input type="color" value={editingKat?.farbe ?? '#1976d2'} onChange={(e) => setEditingKat((k) => k ? { ...k, farbe: e.target.value } : k)} style={{ width: 40, height: 36, border: 'none', cursor: 'pointer', borderRadius: 4 }} />
<Typography variant="body2" color="text.secondary">{editingKat?.farbe}</Typography>
</Box>
<TextField label="Beschreibung" value={editingKat?.beschreibung ?? ''} onChange={(e) => setEditingKat((k) => k ? { ...k, beschreibung: e.target.value || null } : k)} fullWidth multiline rows={2} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setEditingKat(null)}>Abbrechen</Button>
<Button variant="contained" onClick={handleUpdate} disabled={saving || !editingKat?.name.trim()}>
{saving ? <CircularProgress size={20} /> : 'Speichern'}
</Button>
</DialogActions>
</Dialog>
{/* New category dialog */}
<Dialog open={newKatOpen} onClose={() => setNewKatOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Neue Kategorie</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Name" value={newKatForm.name} onChange={(e) => setNewKatForm((f) => ({ ...f, name: e.target.value }))} fullWidth required />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="body2">Farbe</Typography>
<input type="color" value={newKatForm.farbe} onChange={(e) => setNewKatForm((f) => ({ ...f, farbe: e.target.value }))} style={{ width: 40, height: 36, border: 'none', cursor: 'pointer', borderRadius: 4 }} />
<Typography variant="body2" color="text.secondary">{newKatForm.farbe}</Typography>
</Box>
<TextField label="Beschreibung" value={newKatForm.beschreibung} onChange={(e) => setNewKatForm((f) => ({ ...f, beschreibung: e.target.value }))} fullWidth multiline rows={2} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setNewKatOpen(false)}>Abbrechen</Button>
<Button variant="contained" onClick={handleCreate} disabled={saving || !newKatForm.name.trim()}>
{saving ? <CircularProgress size={20} /> : 'Erstellen'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
// ────────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────────
// Main Kalender Page // Main Kalender Page
// ────────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────────
@@ -1633,6 +1786,11 @@ export default function Kalender() {
const canWriteEvents = hasPermission('kalender:create'); const canWriteEvents = hasPermission('kalender:create');
// ── Tab / search params ───────────────────────────────────────────────────
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = Number(searchParams.get('tab') ?? 0);
const setActiveTab = (n: number) => setSearchParams({ tab: String(n) });
// ── Calendar state ───────────────────────────────────────────────────────── // ── Calendar state ─────────────────────────────────────────────────────────
const today = new Date(); const today = new Date();
const [viewMonth, setViewMonth] = useState({ const [viewMonth, setViewMonth] = useState({
@@ -1664,6 +1822,8 @@ export default function Kalender() {
const [cancelEventLoading, setCancelEventLoading] = useState(false); const [cancelEventLoading, setCancelEventLoading] = useState(false);
const [deleteEventId, setDeleteEventId] = useState<string | null>(null); const [deleteEventId, setDeleteEventId] = useState<string | null>(null);
const [deleteEventLoading, setDeleteEventLoading] = useState(false); const [deleteEventLoading, setDeleteEventLoading] = useState(false);
const [deleteMode, setDeleteMode] = useState<'single' | 'future' | 'all'>('all');
const [editScopeEvent, setEditScopeEvent] = useState<VeranstaltungListItem | null>(null);
// iCal subscription // iCal subscription
const [icalEventOpen, setIcalEventOpen] = useState(false); const [icalEventOpen, setIcalEventOpen] = useState(false);
@@ -1850,7 +2010,7 @@ export default function Kalender() {
if (!deleteEventId) return; if (!deleteEventId) return;
setDeleteEventLoading(true); setDeleteEventLoading(true);
try { try {
await eventsApi.deleteEvent(deleteEventId); await eventsApi.deleteEvent(deleteEventId, deleteMode);
notification.showSuccess('Veranstaltung wurde endgültig gelöscht'); notification.showSuccess('Veranstaltung wurde endgültig gelöscht');
setDeleteEventId(null); setDeleteEventId(null);
loadCalendarData(); loadCalendarData();
@@ -1861,6 +2021,20 @@ export default function Kalender() {
} }
}; };
const handleOpenDeleteDialog = useCallback((id: string) => {
setDeleteMode('all');
setDeleteEventId(id);
}, []);
const handleEventEdit = useCallback(async (ev: VeranstaltungListItem) => {
if (ev.wiederholung_parent_id) {
setEditScopeEvent(ev);
return;
}
setVeranstEditing(ev);
setVeranstFormOpen(true);
}, []);
const handleIcalEventOpen = async () => { const handleIcalEventOpen = async () => {
try { try {
const { subscribeUrl } = await eventsApi.getCalendarToken(); const { subscribeUrl } = await eventsApi.getCalendarToken();
@@ -1889,6 +2063,15 @@ export default function Kalender() {
</Typography> </Typography>
</Box> </Box>
{canWriteEvents ? (
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)} sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}>
<Tab label="Kalender" value={0} />
<Tab icon={<SettingsIcon fontSize="small" />} iconPosition="start" label="Einstellungen" value={1} />
</Tabs>
) : null}
{activeTab === 0 && (
<>
{/* ── Calendar ───────────────────────────────────────────── */} {/* ── Calendar ───────────────────────────────────────────── */}
<Box> <Box>
{/* Controls row */} {/* Controls row */}
@@ -1941,18 +2124,6 @@ export default function Kalender() {
</Tooltip> </Tooltip>
</ButtonGroup> </ButtonGroup>
{/* Kategorien verwalten */}
{canWriteEvents && (
<Tooltip title="Kategorien verwalten">
<IconButton
size="small"
onClick={() => navigate('/veranstaltungen/kategorien')}
>
<Tune fontSize="small" />
</IconButton>
</Tooltip>
)}
{/* PDF Export — available in all views */} {/* PDF Export — available in all views */}
<Tooltip title="Als PDF exportieren"> <Tooltip title="Als PDF exportieren">
<IconButton <IconButton
@@ -2259,15 +2430,12 @@ export default function Kalender() {
selectedKategorie={selectedKategorie} selectedKategorie={selectedKategorie}
canWriteEvents={canWriteEvents} canWriteEvents={canWriteEvents}
onTrainingClick={(id) => navigate(`/training/${id}`)} onTrainingClick={(id) => navigate(`/training/${id}`)}
onEventEdit={(ev) => { onEventEdit={handleEventEdit}
setVeranstEditing(ev);
setVeranstFormOpen(true);
}}
onEventCancel={(id) => { onEventCancel={(id) => {
setCancelEventId(id); setCancelEventId(id);
setCancelEventGrund(''); setCancelEventGrund('');
}} }}
onEventDelete={(id) => setDeleteEventId(id)} onEventDelete={handleOpenDeleteDialog}
/> />
</Paper> </Paper>
</> </>
@@ -2294,11 +2462,8 @@ export default function Kalender() {
canWriteEvents={canWriteEvents} canWriteEvents={canWriteEvents}
onClose={() => setPopoverAnchor(null)} onClose={() => setPopoverAnchor(null)}
onTrainingClick={(id) => navigate(`/training/${id}`)} onTrainingClick={(id) => navigate(`/training/${id}`)}
onEventEdit={(ev) => { onEventEdit={handleEventEdit}
setVeranstEditing(ev); onEventDelete={handleOpenDeleteDialog}
setVeranstFormOpen(true);
}}
onEventDelete={(id) => setDeleteEventId(id)}
/> />
{/* Veranstaltung Form Dialog */} {/* Veranstaltung Form Dialog */}
@@ -2347,6 +2512,10 @@ export default function Kalender() {
</Dialog> </Dialog>
{/* Endgültig löschen Dialog */} {/* Endgültig löschen Dialog */}
{(() => {
const deleteEvent = veranstaltungen.find(e => e.id === deleteEventId) ?? null;
const isRecurring = Boolean(deleteEvent?.wiederholung || deleteEvent?.wiederholung_parent_id);
return (
<Dialog <Dialog
open={Boolean(deleteEventId)} open={Boolean(deleteEventId)}
onClose={() => setDeleteEventId(null)} onClose={() => setDeleteEventId(null)}
@@ -2356,8 +2525,19 @@ export default function Kalender() {
<DialogTitle>Veranstaltung endgültig löschen?</DialogTitle> <DialogTitle>Veranstaltung endgültig löschen?</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>
Diese Veranstaltung wird endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden. Diese Aktion kann nicht rückgängig gemacht werden.
</DialogContentText> </DialogContentText>
{isRecurring && (
<RadioGroup
value={deleteMode}
onChange={(e) => setDeleteMode(e.target.value as 'single' | 'future' | 'all')}
sx={{ mt: 2 }}
>
<FormControlLabel value="single" control={<Radio />} label="Nur diesen Termin" />
<FormControlLabel value="future" control={<Radio />} label="Diesen und alle folgenden Termine" />
<FormControlLabel value="all" control={<Radio />} label="Alle Termine der Serie" />
</RadioGroup>
)}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setDeleteEventId(null)} disabled={deleteEventLoading}> <Button onClick={() => setDeleteEventId(null)} disabled={deleteEventLoading}>
@@ -2373,6 +2553,72 @@ export default function Kalender() {
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
);
})()}
{/* Edit scope dialog for recurring event instances */}
<Dialog
open={Boolean(editScopeEvent)}
onClose={() => setEditScopeEvent(null)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Wiederkehrenden Termin bearbeiten</DialogTitle>
<DialogContent>
<DialogContentText>
Welche Termine möchtest du bearbeiten?
</DialogContentText>
</DialogContent>
<DialogActions sx={{ flexDirection: 'column', alignItems: 'stretch', gap: 1, pb: 2, px: 2 }}>
<Button
variant="outlined"
onClick={() => {
if (editScopeEvent) {
setVeranstEditing(editScopeEvent);
setVeranstFormOpen(true);
}
setEditScopeEvent(null);
}}
>
Nur diesen Termin bearbeiten
</Button>
<Button
variant="contained"
onClick={async () => {
if (!editScopeEvent?.wiederholung_parent_id) return;
try {
const parent = await eventsApi.getById(editScopeEvent.wiederholung_parent_id);
setVeranstEditing({
id: parent.id,
titel: parent.titel,
beschreibung: parent.beschreibung,
datum_von: parent.datum_von,
datum_bis: parent.datum_bis,
ganztaegig: parent.ganztaegig,
ort: parent.ort,
kategorie_id: parent.kategorie_id,
kategorie_name: parent.kategorie_name,
kategorie_farbe: parent.kategorie_farbe,
kategorie_icon: parent.kategorie_icon,
wiederholung: parent.wiederholung,
wiederholung_parent_id: null,
alle_gruppen: parent.alle_gruppen,
zielgruppen: parent.zielgruppen ?? [],
anmeldung_erforderlich: parent.anmeldung_erforderlich,
abgesagt: parent.abgesagt,
});
setVeranstFormOpen(true);
} catch {
notification.showError('Fehler beim Laden der Serie');
}
setEditScopeEvent(null);
}}
>
Alle Termine der Serie bearbeiten
</Button>
<Button onClick={() => setEditScopeEvent(null)}>Abbrechen</Button>
</DialogActions>
</Dialog>
{/* iCal Event subscription dialog */} {/* iCal Event subscription dialog */}
<Dialog open={icalEventOpen} onClose={() => setIcalEventOpen(false)} maxWidth="sm" fullWidth> <Dialog open={icalEventOpen} onClose={() => setIcalEventOpen(false)} maxWidth="sm" fullWidth>
@@ -2414,6 +2660,12 @@ export default function Kalender() {
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</Box> </Box>
</>
)}
{activeTab === 1 && canWriteEvents && (
<SettingsTab kategorien={kategorien} onKategorienChange={setKategorien} />
)}
</Box> </Box>

View File

@@ -134,6 +134,6 @@ export const kategorieApi = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function fetchVehicles(): Promise<Fahrzeug[]> { export function fetchVehicles(): Promise<Fahrzeug[]> {
return api return api
.get<ApiResponse<Fahrzeug[]>>('/api/vehicles') .get<ApiResponse<Fahrzeug[]>>('/api/bookings/vehicles')
.then((r) => r.data.data); .then((r) => r.data.data);
} }