calendar and vehicle booking rework

This commit is contained in:
Matthias Hochmeister
2026-03-25 15:44:11 +01:00
parent e49639e2a6
commit 74d978171c
12 changed files with 1413 additions and 1835 deletions

View File

@@ -103,6 +103,7 @@ import bannerRoutes from './routes/banner.routes';
import permissionRoutes from './routes/permission.routes'; import permissionRoutes from './routes/permission.routes';
import ausruestungsanfrageRoutes from './routes/ausruestungsanfrage.routes'; import ausruestungsanfrageRoutes from './routes/ausruestungsanfrage.routes';
import issueRoutes from './routes/issue.routes'; import issueRoutes from './routes/issue.routes';
import buchungskategorieRoutes from './routes/buchungskategorie.routes';
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/user', userRoutes); app.use('/api/user', userRoutes);
@@ -128,6 +129,7 @@ app.use('/api/banners', bannerRoutes);
app.use('/api/permissions', permissionRoutes); app.use('/api/permissions', permissionRoutes);
app.use('/api/ausruestungsanfragen', ausruestungsanfrageRoutes); app.use('/api/ausruestungsanfragen', ausruestungsanfrageRoutes);
app.use('/api/issues', issueRoutes); app.use('/api/issues', issueRoutes);
app.use('/api/buchungskategorien', buchungskategorieRoutes);
// Static file serving for uploads (authenticated) // Static file serving for uploads (authenticated)
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads'); const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');

View File

@@ -0,0 +1,101 @@
import { Request, Response } from 'express';
import buchungskategorieService from '../services/buchungskategorie.service';
import logger from '../utils/logger';
const param = (req: Request, key: string): string => req.params[key] as string;
class BuchungsKategorieController {
async list(_req: Request, res: Response): Promise<void> {
try {
const kategorien = await buchungskategorieService.getAll();
res.status(200).json({ success: true, data: kategorien });
} catch (error) {
logger.error('BuchungsKategorieController.list error', { error });
res.status(500).json({ success: false, message: 'Buchungskategorien konnten nicht geladen werden' });
}
}
async listActive(_req: Request, res: Response): Promise<void> {
try {
const kategorien = await buchungskategorieService.getActive();
res.status(200).json({ success: true, data: kategorien });
} catch (error) {
logger.error('BuchungsKategorieController.listActive error', { error });
res.status(500).json({ success: false, message: 'Buchungskategorien konnten nicht geladen werden' });
}
}
async create(req: Request, res: Response): Promise<void> {
const { bezeichnung } = req.body;
if (!bezeichnung || typeof bezeichnung !== 'string' || bezeichnung.trim().length === 0) {
res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' });
return;
}
try {
const kategorie = await buchungskategorieService.create(req.body);
res.status(201).json({ success: true, data: kategorie });
} catch (error) {
logger.error('BuchungsKategorieController.create error', { error });
res.status(500).json({ success: false, message: 'Buchungskategorie konnte nicht erstellt werden' });
}
}
async update(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
try {
const kategorie = await buchungskategorieService.update(id, req.body);
if (!kategorie) {
res.status(404).json({ success: false, message: 'Buchungskategorie nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: kategorie });
} catch (error) {
logger.error('BuchungsKategorieController.update error', { error });
res.status(500).json({ success: false, message: 'Buchungskategorie konnte nicht aktualisiert werden' });
}
}
async deactivate(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
try {
const kategorie = await buchungskategorieService.deactivate(id);
if (!kategorie) {
res.status(404).json({ success: false, message: 'Buchungskategorie nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: kategorie });
} catch (error) {
logger.error('BuchungsKategorieController.deactivate error', { error });
res.status(500).json({ success: false, message: 'Buchungskategorie konnte nicht deaktiviert werden' });
}
}
async remove(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
try {
const kategorie = await buchungskategorieService.remove(id);
if (!kategorie) {
res.status(404).json({ success: false, message: 'Buchungskategorie nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: kategorie });
} catch (error) {
logger.error('BuchungsKategorieController.remove error', { error });
res.status(500).json({ success: false, message: 'Buchungskategorie konnte nicht gelöscht werden' });
}
}
}
export default new BuchungsKategorieController();

View File

@@ -0,0 +1,22 @@
-- =============================================================================
-- Migration 062: Buchungskategorien (Booking Categories)
-- Replaces the fahrzeug_buchung_art ENUM with a configurable categories table.
-- =============================================================================
CREATE TABLE IF NOT EXISTS buchungs_kategorien (
id SERIAL PRIMARY KEY,
bezeichnung VARCHAR(100) NOT NULL UNIQUE,
farbe VARCHAR(7) DEFAULT '#607D8B',
aktiv BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
erstellt_am TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
INSERT INTO buchungs_kategorien (bezeichnung, farbe, sort_order) VALUES
('intern', '#1976D2', 1),
('extern', '#388E3C', 2),
('wartung', '#F57C00', 3),
('reservierung', '#7B1FA2', 4),
('lehrgang', '#D32F2F', 5),
('sonstiges', '#607D8B', 6)
ON CONFLICT (bezeichnung) DO NOTHING;

View File

@@ -0,0 +1,56 @@
import { Router } from 'express';
import buchungskategorieController from '../controllers/buchungskategorie.controller';
import { authenticate } from '../middleware/auth.middleware';
import { requirePermission } from '../middleware/rbac.middleware';
const router = Router();
// GET / — all categories (admin view, includes inactive)
router.get(
'/',
authenticate,
requirePermission('kalender:view_bookings'),
buchungskategorieController.list.bind(buchungskategorieController)
);
// GET /active — only active categories (for booking forms)
router.get(
'/active',
authenticate,
requirePermission('kalender:view_bookings'),
buchungskategorieController.listActive.bind(buchungskategorieController)
);
// POST / — create new category
router.post(
'/',
authenticate,
requirePermission('kalender:manage_bookings'),
buchungskategorieController.create.bind(buchungskategorieController)
);
// PATCH /:id — update category
router.patch(
'/:id',
authenticate,
requirePermission('kalender:manage_bookings'),
buchungskategorieController.update.bind(buchungskategorieController)
);
// DELETE /:id — soft-delete (deactivate)
router.delete(
'/:id',
authenticate,
requirePermission('kalender:manage_bookings'),
buchungskategorieController.deactivate.bind(buchungskategorieController)
);
// DELETE /:id/permanent — hard delete
router.delete(
'/:id/permanent',
authenticate,
requirePermission('kalender:manage_bookings'),
buchungskategorieController.remove.bind(buchungskategorieController)
);
export default router;

View File

@@ -0,0 +1,105 @@
// =============================================================================
// BuchungsKategorie (Booking Category) Service
// =============================================================================
import pool from '../config/database';
import logger from '../utils/logger';
interface CreateKategorieData {
bezeichnung: string;
farbe?: string;
sort_order?: number;
}
interface UpdateKategorieData {
bezeichnung?: string;
farbe?: string;
aktiv?: boolean;
sort_order?: number;
}
async function getAll() {
try {
const result = await pool.query(
`SELECT * FROM buchungs_kategorien ORDER BY sort_order, bezeichnung`
);
return result.rows;
} catch (error) {
logger.error('BuchungsKategorieService.getAll failed', { error });
throw new Error('Buchungskategorien konnten nicht geladen werden');
}
}
async function getActive() {
try {
const result = await pool.query(
`SELECT * FROM buchungs_kategorien WHERE aktiv = TRUE ORDER BY sort_order, bezeichnung`
);
return result.rows;
} catch (error) {
logger.error('BuchungsKategorieService.getActive failed', { error });
throw new Error('Buchungskategorien konnten nicht geladen werden');
}
}
async function create(data: CreateKategorieData) {
try {
const result = await pool.query(
`INSERT INTO buchungs_kategorien (bezeichnung, farbe, sort_order)
VALUES ($1, $2, $3)
RETURNING *`,
[data.bezeichnung, data.farbe || '#607D8B', data.sort_order || 0]
);
return result.rows[0];
} catch (error) {
logger.error('BuchungsKategorieService.create failed', { error });
throw new Error('Buchungskategorie konnte nicht erstellt werden');
}
}
async function update(id: number, data: UpdateKategorieData) {
try {
const result = await pool.query(
`UPDATE buchungs_kategorien
SET bezeichnung = COALESCE($1, bezeichnung),
farbe = COALESCE($2, farbe),
aktiv = COALESCE($3, aktiv),
sort_order = COALESCE($4, sort_order)
WHERE id = $5
RETURNING *`,
[data.bezeichnung, data.farbe, data.aktiv, data.sort_order, id]
);
return result.rows[0] || null;
} catch (error) {
logger.error('BuchungsKategorieService.update failed', { error, id });
throw new Error('Buchungskategorie konnte nicht aktualisiert werden');
}
}
async function deactivate(id: number) {
try {
const result = await pool.query(
`UPDATE buchungs_kategorien SET aktiv = FALSE WHERE id = $1 RETURNING *`,
[id]
);
return result.rows[0] || null;
} catch (error) {
logger.error('BuchungsKategorieService.deactivate failed', { error, id });
throw new Error('Buchungskategorie konnte nicht deaktiviert werden');
}
}
async function remove(id: number) {
try {
const result = await pool.query(
`DELETE FROM buchungs_kategorien WHERE id = $1 RETURNING *`,
[id]
);
return result.rows[0] || null;
} catch (error) {
logger.error('BuchungsKategorieService.remove failed', { error, id });
throw new Error('Buchungskategorie konnte nicht gelöscht werden');
}
}
export default { getAll, getActive, create, update, deactivate, remove };

View File

@@ -15,6 +15,7 @@ import Fahrzeuge from './pages/Fahrzeuge';
import FahrzeugDetail from './pages/FahrzeugDetail'; import FahrzeugDetail from './pages/FahrzeugDetail';
import FahrzeugForm from './pages/FahrzeugForm'; import FahrzeugForm from './pages/FahrzeugForm';
import FahrzeugBuchungen from './pages/FahrzeugBuchungen'; import FahrzeugBuchungen from './pages/FahrzeugBuchungen';
import BookingFormPage from './pages/BookingFormPage';
import Ausruestung from './pages/Ausruestung'; import Ausruestung from './pages/Ausruestung';
import AusruestungForm from './pages/AusruestungForm'; import AusruestungForm from './pages/AusruestungForm';
import AusruestungDetail from './pages/AusruestungDetail'; import AusruestungDetail from './pages/AusruestungDetail';
@@ -219,6 +220,22 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/fahrzeugbuchungen/neu"
element={
<ProtectedRoute>
<BookingFormPage />
</ProtectedRoute>
}
/>
<Route
path="/fahrzeugbuchungen/:id"
element={
<ProtectedRoute>
<BookingFormPage />
</ProtectedRoute>
}
/>
<Route <Route
path="/wissen" path="/wissen"
element={ element={

View File

@@ -50,8 +50,8 @@ interface NavigationItem {
} }
const kalenderSubItems: SubItem[] = [ const kalenderSubItems: SubItem[] = [
{ text: 'Veranstaltungen', path: '/kalender?tab=0' }, { text: 'Veranstaltungen', path: '/kalender' },
{ text: 'Fahrzeugbuchungen', path: '/kalender?tab=1' }, { text: 'Fahrzeugbuchungen', path: '/fahrzeugbuchungen' },
]; ];
const adminSubItems: SubItem[] = [ const adminSubItems: SubItem[] = [

View File

@@ -0,0 +1,544 @@
import { useState, useEffect } from 'react';
import {
Box,
Typography,
Paper,
Button,
TextField,
Select,
MenuItem,
FormControl,
FormControlLabel,
InputLabel,
CircularProgress,
Alert,
Switch,
IconButton,
Chip,
Stack,
} from '@mui/material';
import {
ArrowBack,
CheckCircle,
Warning,
Block,
} from '@mui/icons-material';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useNotification } from '../contexts/NotificationContext';
import { bookingApi, fetchVehicles, kategorieApi } from '../services/bookings';
import type { CreateBuchungInput, BuchungsArt } from '../types/booking.types';
import { BUCHUNGS_ART_LABELS } from '../types/booking.types';
const EMPTY_FORM: CreateBuchungInput = {
fahrzeugId: '',
titel: '',
beschreibung: '',
beginn: '',
ende: '',
buchungsArt: 'intern',
kontaktPerson: '',
kontaktTelefon: '',
ganztaegig: false,
};
function BookingFormPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const notification = useNotification();
const isEdit = Boolean(id);
const [form, setForm] = useState<CreateBuchungInput>({ ...EMPTY_FORM });
const [overrideOutOfService, setOverrideOutOfService] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [availability, setAvailability] = useState<{
available: boolean;
reason?: string;
ausserDienstBis?: string;
} | null>(null);
// Fetch vehicles
const { data: vehicles = [] } = useQuery({
queryKey: ['vehicles'],
queryFn: fetchVehicles,
});
// Fetch active categories
const { data: kategorien = [] } = useQuery({
queryKey: ['buchungskategorien'],
queryFn: kategorieApi.getActive,
});
// Fetch existing booking for edit mode
const { data: existingBooking, isLoading: loadingBooking } = useQuery({
queryKey: ['booking', id],
queryFn: () => bookingApi.getById(id!),
enabled: !!id,
});
// Pre-fill form when editing
useEffect(() => {
if (existingBooking) {
const beginn = existingBooking.ganztaegig
? existingBooking.beginn.split('T')[0]
: existingBooking.beginn.slice(0, 16); // yyyy-MM-ddTHH:mm
const ende = existingBooking.ganztaegig
? existingBooking.ende.split('T')[0]
: existingBooking.ende.slice(0, 16);
setForm({
fahrzeugId: existingBooking.fahrzeug_id,
titel: existingBooking.titel,
beschreibung: existingBooking.beschreibung || '',
beginn: existingBooking.ganztaegig ? `${beginn}T00:00` : beginn,
ende: existingBooking.ganztaegig ? `${ende}T23:59` : ende,
buchungsArt: existingBooking.buchungs_art,
kontaktPerson: existingBooking.kontakt_person || '',
kontaktTelefon: existingBooking.kontakt_telefon || '',
ganztaegig: existingBooking.ganztaegig || false,
});
}
}, [existingBooking]);
// Check availability whenever relevant form fields change
useEffect(() => {
if (!form.fahrzeugId || !form.beginn || !form.ende) {
setAvailability(null);
return;
}
const beginn = new Date(form.beginn);
const ende = new Date(form.ende);
if (isNaN(beginn.getTime()) || isNaN(ende.getTime()) || ende <= beginn) {
setAvailability(null);
return;
}
let cancelled = false;
const timer = setTimeout(() => {
bookingApi
.checkAvailability(form.fahrzeugId, beginn, ende, id)
.then((result) => {
if (!cancelled) setAvailability(result);
})
.catch(() => {
if (!cancelled) setAvailability(null);
});
}, 300);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [form.fahrzeugId, form.beginn, form.ende, id]);
// Determine if the selected category is "extern"-like (show contact fields)
const selectedKategorie = kategorien.find(
(k) => k.bezeichnung.toLowerCase() === form.buchungsArt
);
const isExtern =
form.buchungsArt === 'extern' ||
selectedKategorie?.bezeichnung.toLowerCase() === 'extern';
// Date validity check
const formBeginnDate = form.beginn ? new Date(form.beginn) : null;
const formEndeDate = form.ende ? new Date(form.ende) : null;
const formDatesValid = !!(
formBeginnDate &&
formEndeDate &&
!isNaN(formBeginnDate.getTime()) &&
!isNaN(formEndeDate.getTime()) &&
formEndeDate > formBeginnDate
);
const handleSave = async () => {
setSaving(true);
setError(null);
try {
const beginnDate = new Date(form.beginn);
const endeDate = new Date(form.ende);
if (isNaN(beginnDate.getTime()) || isNaN(endeDate.getTime())) {
setError('Ungültiges Datum. Bitte Beginn und Ende prüfen.');
setSaving(false);
return;
}
if (endeDate <= beginnDate) {
setError('Ende muss nach dem Beginn liegen.');
setSaving(false);
return;
}
const payload: CreateBuchungInput = {
...form,
beginn: beginnDate.toISOString(),
ende: endeDate.toISOString(),
ganztaegig: form.ganztaegig || false,
};
if (isEdit && id) {
await bookingApi.update(id, payload);
notification.showSuccess('Buchung aktualisiert');
} else {
await bookingApi.create({
...payload,
ignoreOutOfService: overrideOutOfService,
} as any);
notification.showSuccess('Buchung erstellt');
}
navigate('/fahrzeugbuchungen');
} catch (e: unknown) {
try {
const axiosError = e as {
response?: {
status?: number;
data?: { message?: string; reason?: string };
};
message?: string;
};
if (axiosError?.response?.status === 409) {
const reason = axiosError?.response?.data?.reason;
if (reason === 'out_of_service') {
setError(
axiosError?.response?.data?.message ||
'Fahrzeug ist im gewählten Zeitraum außer Dienst'
);
} else {
setError(
axiosError?.response?.data?.message ||
'Fahrzeug ist im gewählten Zeitraum bereits gebucht'
);
}
} else {
setError(
axiosError?.response?.data?.message ||
axiosError?.message ||
'Fehler beim Speichern'
);
}
} catch {
setError(e instanceof Error ? e.message : 'Fehler beim Speichern');
}
} finally {
setSaving(false);
}
};
if (isEdit && loadingBooking) {
return (
<DashboardLayout>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<Box sx={{ p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
<IconButton onClick={() => navigate('/fahrzeugbuchungen')}>
<ArrowBack />
</IconButton>
<Typography variant="h5" sx={{ ml: 1 }}>
{isEdit ? 'Buchung bearbeiten' : 'Neue Buchung'}
</Typography>
</Box>
<Paper sx={{ p: 3, maxWidth: 600 }}>
{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>
<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`,
};
}
return { ...f, ganztaegig: checked };
});
}}
/>
}
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"
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()}>
<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>
<Box sx={{ mt: 3, display: 'flex', gap: 2 }}>
<Button
variant="outlined"
onClick={() => navigate('/fahrzeugbuchungen')}
>
Abbrechen
</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={
saving ||
!form.titel ||
!form.fahrzeugId ||
!form.beginn ||
!form.ende
}
>
{saving ? <CircularProgress size={20} /> : 'Speichern'}
</Button>
</Box>
</Paper>
</Box>
</DashboardLayout>
);
}
export default BookingFormPage;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import type {
Fahrzeug, Fahrzeug,
CreateBuchungInput, CreateBuchungInput,
MaintenanceWindow, MaintenanceWindow,
BuchungsKategorie,
} from '../types/booking.types'; } from '../types/booking.types';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -117,6 +118,17 @@ export const bookingApi = {
}, },
}; };
// ---------------------------------------------------------------------------
// Booking categories
// ---------------------------------------------------------------------------
export const kategorieApi = {
getAll: () => api.get<BuchungsKategorie[]>('/api/buchungskategorien').then((r) => r.data),
getActive: () => api.get<BuchungsKategorie[]>('/api/buchungskategorien/active').then((r) => r.data),
create: (data: Omit<BuchungsKategorie, 'id'>) => api.post<BuchungsKategorie>('/api/buchungskategorien', data).then((r) => r.data),
update: (id: number, data: Partial<BuchungsKategorie>) => api.patch<BuchungsKategorie>(`/api/buchungskategorien/${id}`, data).then((r) => r.data),
delete: (id: number) => api.delete(`/api/buchungskategorien/${id}`).then((r) => r.data),
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Vehicle helper (shared with booking page) // Vehicle helper (shared with booking page)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -63,6 +63,14 @@ export interface MaintenanceWindow {
ausser_dienst_bis: string; ausser_dienst_bis: string;
} }
export interface BuchungsKategorie {
id: number;
bezeichnung: string;
farbe: string;
aktiv: boolean;
sort_order: number;
}
export interface CreateBuchungInput { export interface CreateBuchungInput {
fahrzeugId: string; fahrzeugId: string;
titel: string; titel: string;