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 bookingService from '../services/booking.service';
import vehicleService from '../services/vehicle.service';
import pool from '../config/database';
import { permissionService } from '../services/permission.service';
import {
CreateBuchungSchema,
@@ -43,6 +44,22 @@ function handleConflictError(res: Response, err: Error): boolean {
// ---------------------------------------------------------------------------
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=
* 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) ──────────────────────────────────────
router.get('/vehicles', authenticate, bookingController.getVehiclesForBooking.bind(bookingController));
router.get('/calendar', authenticate, bookingController.getCalendarRange.bind(bookingController));
router.get('/upcoming', authenticate, bookingController.getUpcoming.bind(bookingController));
router.get('/availability', authenticate, bookingController.checkAvailability.bind(bookingController));

View File

@@ -2,7 +2,6 @@ import { useState, useEffect } from 'react';
import {
Box,
Typography,
Paper,
Button,
TextField,
Select,
@@ -16,6 +15,11 @@ import {
IconButton,
Chip,
Stack,
Card,
CardContent,
CardHeader,
Container,
Grid,
} from '@mui/material';
import {
ArrowBack,
@@ -237,7 +241,8 @@ function BookingFormPage() {
{!isFeatureEnabled('fahrzeugbuchungen') ? (
<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 }}>
<IconButton onClick={() => navigate('/fahrzeugbuchungen')}>
<ArrowBack />
@@ -247,280 +252,300 @@ function BookingFormPage() {
</Typography>
</Box>
<Paper sx={{ p: 3, maxWidth: 600 }}>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Stack spacing={2}>
<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.bezeichnung}
{v.amtliches_kennzeichen
? ` (${v.amtliches_kennzeichen})`
: ''}
</MenuItem>
))}
</Select>
</FormControl>
<Stack spacing={3}>
{/* Card 1: Fahrzeug & Zeitraum */}
<Card variant="outlined">
<CardHeader title="Fahrzeug & Zeitraum" titleTypographyProps={{ variant: 'subtitle1', fontWeight: 600 }} />
<CardContent>
<Stack spacing={2}>
<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.bezeichnung}
{v.amtliches_kennzeichen
? ` (${v.amtliches_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 }))
}
/>
<FormControlLabel
control={
<Switch
checked={form.ganztaegig || false}
onChange={(e) => {
const checked = e.target.checked;
setForm((f) => {
if (checked && f.beginn) {
const dateStr = f.beginn.split('T')[0];
return {
...f,
ganztaegig: true,
beginn: `${dateStr}T00:00`,
ende: f.ende
? `${f.ende.split('T')[0]}T23:59`
: `${dateStr}T23:59`,
};
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
size="small"
label="Beginn"
type={form.ganztaegig ? 'date' : 'datetime-local'}
required
value={
form.ganztaegig
? form.beginn?.split('T')[0] || ''
: form.beginn
}
return { ...f, ganztaegig: checked };
});
}}
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
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 }}
/>
</Grid>
</Grid>
<FormControlLabel
control={
<Switch
checked={form.ganztaegig || false}
onChange={(e) => {
const checked = e.target.checked;
setForm((f) => {
if (checked && f.beginn) {
const dateStr = f.beginn.split('T')[0];
return {
...f,
ganztaegig: true,
beginn: `${dateStr}T00:00`,
ende: f.ende
? `${f.ende.split('T')[0]}T23:59`
: `${dateStr}T23:59`,
};
}
return { ...f, ganztaegig: checked };
});
}}
/>
}
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 */}
{form.fahrzeugId && form.beginn && form.ende ? (
!formDatesValid ? (
<Typography
variant="body2"
color="error"
sx={{ fontSize: '0.75rem' }}
>
Ende muss nach dem Beginn liegen
</Typography>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{availability === null ? (
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
>
<CircularProgress size={16} />
<Typography variant="body2" color="text.secondary">
Verfügbarkeit wird geprüft...
</Typography>
</Box>
) : availability.available ? (
<Chip
icon={<CheckCircle />}
label="Fahrzeug verfügbar"
color="success"
size="small"
/>
) : availability.reason === 'out_of_service' ? (
<Box>
<Chip
icon={<Block />}
label={
availability.ausserDienstBis
? `Außer Dienst bis ${new Date(availability.ausserDienstBis).toLocaleDateString('de-DE')} (geschätzt)`
: 'Fahrzeug ist außer Dienst'
}
color="error"
size="small"
/>
<FormControlLabel
control={
<Switch
checked={overrideOutOfService}
onChange={(e) =>
setOverrideOutOfService(e.target.checked)
}
color="warning"
size="small"
/>
}
label={
<Typography variant="body2" color="warning.main">
Trotz Außer-Dienst-Status buchen
</Typography>
}
sx={{ mt: 0.5 }}
/>
</Box>
) : (
<Chip
icon={<Warning />}
label="Konflikt: bereits gebucht"
{/* Availability indicator */}
{form.fahrzeugId && form.beginn && form.ende ? (
!formDatesValid ? (
<Typography
variant="body2"
color="error"
size="small"
/>
)}
</Box>
)
) : (
<Typography
variant="body2"
color="text.secondary"
sx={{ fontSize: '0.75rem' }}
>
Wähle Fahrzeug und Zeitraum für Verfügbarkeitsprüfung
</Typography>
)}
<FormControl fullWidth size="small">
<InputLabel>Kategorie</InputLabel>
<Select
value={form.buchungsArt}
onChange={(e) =>
setForm((f) => ({
...f,
buchungsArt: e.target.value as BuchungsArt,
}))
}
label="Kategorie"
>
{kategorien.length > 0
? kategorien.map((k) => (
<MenuItem key={k.id} value={k.bezeichnung.toLowerCase()}>
sx={{ fontSize: '0.75rem' }}
>
Ende muss nach dem Beginn liegen
</Typography>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{availability === null ? (
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
>
<Box
sx={{
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: k.farbe,
flexShrink: 0,
}}
/>
{k.bezeichnung}
<CircularProgress size={16} />
<Typography variant="body2" color="text.secondary">
Verfügbarkeit wird geprüft...
</Typography>
</Box>
</MenuItem>
))
: (
Object.entries(BUCHUNGS_ART_LABELS) as [
BuchungsArt,
string,
][]
).map(([art, label]) => (
<MenuItem key={art} value={art}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
) : availability.available ? (
<Chip
icon={<CheckCircle />}
label="Fahrzeug verfügbar"
color="success"
size="small"
/>
) : availability.reason === 'out_of_service' ? (
<Box>
<Chip
icon={<Block />}
label={
availability.ausserDienstBis
? `Außer Dienst bis ${new Date(availability.ausserDienstBis).toLocaleDateString('de-DE')} (geschätzt)`
: 'Fahrzeug ist außer Dienst'
}
color="error"
size="small"
/>
<FormControlLabel
control={
<Switch
checked={overrideOutOfService}
onChange={(e) =>
setOverrideOutOfService(e.target.checked)
}
color="warning"
size="small"
/>
}
label={
<Typography variant="body2" color="warning.main">
Trotz Außer-Dienst-Status buchen
</Typography>
}
sx={{ mt: 0.5 }}
/>
</Box>
) : (
<Chip
icon={<Warning />}
label="Konflikt: bereits gebucht"
color="error"
size="small"
/>
)}
</Box>
)
) : (
<Typography
variant="body2"
color="text.secondary"
sx={{ fontSize: '0.75rem' }}
>
Wähle Fahrzeug und Zeitraum für Verfügbarkeitsprüfung
</Typography>
)}
</Stack>
</CardContent>
</Card>
{isExtern && (
<>
{/* Card 2: Details */}
<Card variant="outlined">
<CardHeader title="Details" titleTypographyProps={{ variant: 'subtitle1', fontWeight: 600 }} />
<CardContent>
<Stack spacing={2}>
<TextField
fullWidth
size="small"
label="Kontaktperson"
value={form.kontaktPerson || ''}
label="Titel"
required
value={form.titel}
onChange={(e) =>
setForm((f) => ({ ...f, kontaktPerson: e.target.value }))
setForm((f) => ({ ...f, titel: e.target.value }))
}
/>
<TextField
fullWidth
size="small"
label="Kontakttelefon"
value={form.kontaktTelefon || ''}
label="Beschreibung"
multiline
rows={2}
value={form.beschreibung || ''}
onChange={(e) =>
setForm((f) => ({
...f,
kontaktTelefon: e.target.value,
}))
setForm((f) => ({ ...f, beschreibung: e.target.value }))
}
/>
</>
)}
</Stack>
<Box sx={{ mt: 3, display: 'flex', gap: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Kategorie</InputLabel>
<Select
value={form.buchungsArt}
onChange={(e) =>
setForm((f) => ({
...f,
buchungsArt: e.target.value as BuchungsArt,
}))
}
label="Kategorie"
>
{kategorien.length > 0
? kategorien.map((k) => (
<MenuItem key={k.id} value={k.bezeichnung.toLowerCase()}>
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
>
<Box
sx={{
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: k.farbe,
flexShrink: 0,
}}
/>
{k.bezeichnung}
</Box>
</MenuItem>
))
: (
Object.entries(BUCHUNGS_ART_LABELS) as [
BuchungsArt,
string,
][]
).map(([art, label]) => (
<MenuItem key={art} value={art}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
{isExtern && (
<>
<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>
</CardContent>
</Card>
{/* Actions */}
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
<Button
variant="outlined"
onClick={() => navigate('/fahrzeugbuchungen')}
@@ -541,8 +566,8 @@ function BookingFormPage() {
{saving ? <CircularProgress size={20} /> : 'Speichern'}
</Button>
</Box>
</Paper>
</Box>
</Stack>
</Container>
)}
</DashboardLayout>
);

View File

@@ -24,16 +24,20 @@ import {
MenuItem,
Paper,
Popover,
Radio,
RadioGroup,
Select,
Skeleton,
Stack,
Switch,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tabs,
TextField,
Tooltip,
Typography,
@@ -55,14 +59,14 @@ import {
HelpOutline as UnknownIcon,
IosShare,
PictureAsPdf as PdfIcon,
Settings as SettingsIcon,
Star as StarIcon,
Today as TodayIcon,
Tune,
ViewList as ListViewIcon,
ViewDay as ViewDayIcon,
ViewWeek as ViewWeekIcon,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import ServiceModePage from '../components/shared/ServiceModePage';
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
// ──────────────────────────────────────────────────────────────────────────────
@@ -1633,6 +1786,11 @@ export default function Kalender() {
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 ─────────────────────────────────────────────────────────
const today = new Date();
const [viewMonth, setViewMonth] = useState({
@@ -1664,6 +1822,8 @@ export default function Kalender() {
const [cancelEventLoading, setCancelEventLoading] = useState(false);
const [deleteEventId, setDeleteEventId] = useState<string | null>(null);
const [deleteEventLoading, setDeleteEventLoading] = useState(false);
const [deleteMode, setDeleteMode] = useState<'single' | 'future' | 'all'>('all');
const [editScopeEvent, setEditScopeEvent] = useState<VeranstaltungListItem | null>(null);
// iCal subscription
const [icalEventOpen, setIcalEventOpen] = useState(false);
@@ -1850,7 +2010,7 @@ export default function Kalender() {
if (!deleteEventId) return;
setDeleteEventLoading(true);
try {
await eventsApi.deleteEvent(deleteEventId);
await eventsApi.deleteEvent(deleteEventId, deleteMode);
notification.showSuccess('Veranstaltung wurde endgültig gelöscht');
setDeleteEventId(null);
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 () => {
try {
const { subscribeUrl } = await eventsApi.getCalendarToken();
@@ -1889,6 +2063,15 @@ export default function Kalender() {
</Typography>
</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 ───────────────────────────────────────────── */}
<Box>
{/* Controls row */}
@@ -1941,18 +2124,6 @@ export default function Kalender() {
</Tooltip>
</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 */}
<Tooltip title="Als PDF exportieren">
<IconButton
@@ -2259,15 +2430,12 @@ export default function Kalender() {
selectedKategorie={selectedKategorie}
canWriteEvents={canWriteEvents}
onTrainingClick={(id) => navigate(`/training/${id}`)}
onEventEdit={(ev) => {
setVeranstEditing(ev);
setVeranstFormOpen(true);
}}
onEventEdit={handleEventEdit}
onEventCancel={(id) => {
setCancelEventId(id);
setCancelEventGrund('');
}}
onEventDelete={(id) => setDeleteEventId(id)}
onEventDelete={handleOpenDeleteDialog}
/>
</Paper>
</>
@@ -2294,11 +2462,8 @@ export default function Kalender() {
canWriteEvents={canWriteEvents}
onClose={() => setPopoverAnchor(null)}
onTrainingClick={(id) => navigate(`/training/${id}`)}
onEventEdit={(ev) => {
setVeranstEditing(ev);
setVeranstFormOpen(true);
}}
onEventDelete={(id) => setDeleteEventId(id)}
onEventEdit={handleEventEdit}
onEventDelete={handleOpenDeleteDialog}
/>
{/* Veranstaltung Form Dialog */}
@@ -2347,30 +2512,111 @@ export default function Kalender() {
</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
open={Boolean(deleteEventId)}
onClose={() => setDeleteEventId(null)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Veranstaltung endgültig löschen?</DialogTitle>
<DialogContent>
<DialogContentText>
Diese Aktion kann nicht rückgängig gemacht werden.
</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>
<DialogActions>
<Button onClick={() => setDeleteEventId(null)} disabled={deleteEventLoading}>
Abbrechen
</Button>
<Button
variant="contained"
color="error"
onClick={handleDeleteEvent}
disabled={deleteEventLoading}
>
{deleteEventLoading ? <CircularProgress size={20} /> : 'Endgültig löschen'}
</Button>
</DialogActions>
</Dialog>
);
})()}
{/* Edit scope dialog for recurring event instances */}
<Dialog
open={Boolean(deleteEventId)}
onClose={() => setDeleteEventId(null)}
open={Boolean(editScopeEvent)}
onClose={() => setEditScopeEvent(null)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Veranstaltung endgültig löschen?</DialogTitle>
<DialogTitle>Wiederkehrenden Termin bearbeiten</DialogTitle>
<DialogContent>
<DialogContentText>
Diese Veranstaltung wird endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.
Welche Termine möchtest du bearbeiten?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteEventId(null)} disabled={deleteEventLoading}>
Abbrechen
<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"
color="error"
onClick={handleDeleteEvent}
disabled={deleteEventLoading}
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);
}}
>
{deleteEventLoading ? <CircularProgress size={20} /> : 'Endgültig löschen'}
Alle Termine der Serie bearbeiten
</Button>
<Button onClick={() => setEditScopeEvent(null)}>Abbrechen</Button>
</DialogActions>
</Dialog>
@@ -2414,6 +2660,12 @@ export default function Kalender() {
</DialogActions>
</Dialog>
</Box>
</>
)}
{activeTab === 1 && canWriteEvents && (
<SettingsTab kategorien={kategorien} onKategorienChange={setKategorien} />
)}
</Box>

View File

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