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,280 +252,300 @@ 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={2}> <Stack spacing={3}>
<FormControl fullWidth size="small" required> {/* Card 1: Fahrzeug & Zeitraum */}
<InputLabel>Fahrzeug</InputLabel> <Card variant="outlined">
<Select <CardHeader title="Fahrzeug & Zeitraum" titleTypographyProps={{ variant: 'subtitle1', fontWeight: 600 }} />
value={form.fahrzeugId} <CardContent>
onChange={(e) => <Stack spacing={2}>
setForm((f) => ({ ...f, fahrzeugId: e.target.value })) <FormControl fullWidth size="small" required>
} <InputLabel>Fahrzeug</InputLabel>
label="Fahrzeug" <Select
> value={form.fahrzeugId}
{vehicles.map((v) => ( onChange={(e) =>
<MenuItem key={v.id} value={v.id}> setForm((f) => ({ ...f, fahrzeugId: e.target.value }))
{v.bezeichnung} }
{v.amtliches_kennzeichen label="Fahrzeug"
? ` (${v.amtliches_kennzeichen})` >
: ''} {vehicles.map((v) => (
</MenuItem> <MenuItem key={v.id} value={v.id}>
))} {v.bezeichnung}
</Select> {v.amtliches_kennzeichen
</FormControl> ? ` (${v.amtliches_kennzeichen})`
: ''}
</MenuItem>
))}
</Select>
</FormControl>
<TextField <Grid container spacing={2}>
fullWidth <Grid item xs={12} sm={6}>
size="small" <TextField
label="Titel" fullWidth
required size="small"
value={form.titel} label="Beginn"
onChange={(e) => type={form.ganztaegig ? 'date' : 'datetime-local'}
setForm((f) => ({ ...f, titel: e.target.value })) required
} value={
/> form.ganztaegig
? form.beginn?.split('T')[0] || ''
<TextField : form.beginn
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`,
};
} }
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 {/* Availability indicator */}
fullWidth {form.fahrzeugId && form.beginn && form.ende ? (
size="small" !formDatesValid ? (
label="Beginn" <Typography
type={form.ganztaegig ? 'date' : 'datetime-local'} variant="body2"
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"
color="error" color="error"
size="small" sx={{ fontSize: '0.75rem' }}
/> >
)} Ende muss nach dem Beginn liegen
</Box> </Typography>
) ) : (
) : ( <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography {availability === null ? (
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()}>
<Box <Box
sx={{ display: 'flex', alignItems: 'center', gap: 1 }} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
> >
<Box <CircularProgress size={16} />
sx={{ <Typography variant="body2" color="text.secondary">
width: 12, Verfügbarkeit wird geprüft...
height: 12, </Typography>
borderRadius: '50%',
bgcolor: k.farbe,
flexShrink: 0,
}}
/>
{k.bezeichnung}
</Box> </Box>
</MenuItem> ) : availability.available ? (
)) <Chip
: ( icon={<CheckCircle />}
Object.entries(BUCHUNGS_ART_LABELS) as [ label="Fahrzeug verfügbar"
BuchungsArt, color="success"
string, size="small"
][] />
).map(([art, label]) => ( ) : availability.reason === 'out_of_service' ? (
<MenuItem key={art} value={art}> <Box>
{label} <Chip
</MenuItem> icon={<Block />}
))} label={
</Select> availability.ausserDienstBis
</FormControl> ? `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 <TextField
fullWidth fullWidth
size="small" size="small"
label="Kontaktperson" label="Titel"
value={form.kontaktPerson || ''} required
value={form.titel}
onChange={(e) => onChange={(e) =>
setForm((f) => ({ ...f, kontaktPerson: e.target.value })) setForm((f) => ({ ...f, titel: e.target.value }))
} }
/> />
<TextField <TextField
fullWidth fullWidth
size="small" size="small"
label="Kontakttelefon" label="Beschreibung"
value={form.kontaktTelefon || ''} multiline
rows={2}
value={form.beschreibung || ''}
onChange={(e) => onChange={(e) =>
setForm((f) => ({ setForm((f) => ({ ...f, beschreibung: e.target.value }))
...f,
kontaktTelefon: 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 <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,30 +2512,111 @@ 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
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 <Dialog
open={Boolean(deleteEventId)} open={Boolean(editScopeEvent)}
onClose={() => setDeleteEventId(null)} onClose={() => setEditScopeEvent(null)}
maxWidth="xs" maxWidth="xs"
fullWidth fullWidth
> >
<DialogTitle>Veranstaltung endgültig löschen?</DialogTitle> <DialogTitle>Wiederkehrenden Termin bearbeiten</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>
Diese Veranstaltung wird endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden. Welche Termine möchtest du bearbeiten?
</DialogContentText> </DialogContentText>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions sx={{ flexDirection: 'column', alignItems: 'stretch', gap: 1, pb: 2, px: 2 }}>
<Button onClick={() => setDeleteEventId(null)} disabled={deleteEventLoading}> <Button
Abbrechen variant="outlined"
onClick={() => {
if (editScopeEvent) {
setVeranstEditing(editScopeEvent);
setVeranstFormOpen(true);
}
setEditScopeEvent(null);
}}
>
Nur diesen Termin bearbeiten
</Button> </Button>
<Button <Button
variant="contained" variant="contained"
color="error" onClick={async () => {
onClick={handleDeleteEvent} if (!editScopeEvent?.wiederholung_parent_id) return;
disabled={deleteEventLoading} 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>
<Button onClick={() => setEditScopeEvent(null)}>Abbrechen</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
@@ -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);
} }