calendar and vehicle booking rework
This commit is contained in:
@@ -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');
|
||||||
|
|||||||
101
backend/src/controllers/buchungskategorie.controller.ts
Normal file
101
backend/src/controllers/buchungskategorie.controller.ts
Normal 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();
|
||||||
22
backend/src/database/migrations/062_buchungs_kategorien.sql
Normal file
22
backend/src/database/migrations/062_buchungs_kategorien.sql
Normal 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;
|
||||||
56
backend/src/routes/buchungskategorie.routes.ts
Normal file
56
backend/src/routes/buchungskategorie.routes.ts
Normal 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;
|
||||||
105
backend/src/services/buchungskategorie.service.ts
Normal file
105
backend/src/services/buchungskategorie.service.ts
Normal 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 };
|
||||||
@@ -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={
|
||||||
|
|||||||
@@ -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[] = [
|
||||||
|
|||||||
544
frontend/src/pages/BookingFormPage.tsx
Normal file
544
frontend/src/pages/BookingFormPage.tsx
Normal 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
@@ -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)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user