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