calendar and vehicle booking rework
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user