From 74d978171c3396a93a3f2c3d3078eefa7ffb847f Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Wed, 25 Mar 2026 15:44:11 +0100 Subject: [PATCH] calendar and vehicle booking rework --- backend/src/app.ts | 2 + .../buchungskategorie.controller.ts | 101 ++ .../migrations/062_buchungs_kategorien.sql | 22 + .../src/routes/buchungskategorie.routes.ts | 56 + .../src/services/buchungskategorie.service.ts | 105 ++ frontend/src/App.tsx | 17 + frontend/src/components/shared/Sidebar.tsx | 4 +- frontend/src/pages/BookingFormPage.tsx | 544 ++++++ frontend/src/pages/FahrzeugBuchungen.tsx | 1483 ++++++----------- frontend/src/pages/Kalender.tsx | 894 +--------- frontend/src/services/bookings.ts | 12 + frontend/src/types/booking.types.ts | 8 + 12 files changed, 1413 insertions(+), 1835 deletions(-) create mode 100644 backend/src/controllers/buchungskategorie.controller.ts create mode 100644 backend/src/database/migrations/062_buchungs_kategorien.sql create mode 100644 backend/src/routes/buchungskategorie.routes.ts create mode 100644 backend/src/services/buchungskategorie.service.ts create mode 100644 frontend/src/pages/BookingFormPage.tsx diff --git a/backend/src/app.ts b/backend/src/app.ts index 56fad3e..ba4c39b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -103,6 +103,7 @@ import bannerRoutes from './routes/banner.routes'; import permissionRoutes from './routes/permission.routes'; import ausruestungsanfrageRoutes from './routes/ausruestungsanfrage.routes'; import issueRoutes from './routes/issue.routes'; +import buchungskategorieRoutes from './routes/buchungskategorie.routes'; app.use('/api/auth', authRoutes); app.use('/api/user', userRoutes); @@ -128,6 +129,7 @@ app.use('/api/banners', bannerRoutes); app.use('/api/permissions', permissionRoutes); app.use('/api/ausruestungsanfragen', ausruestungsanfrageRoutes); app.use('/api/issues', issueRoutes); +app.use('/api/buchungskategorien', buchungskategorieRoutes); // Static file serving for uploads (authenticated) const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads'); diff --git a/backend/src/controllers/buchungskategorie.controller.ts b/backend/src/controllers/buchungskategorie.controller.ts new file mode 100644 index 0000000..625ab9d --- /dev/null +++ b/backend/src/controllers/buchungskategorie.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/backend/src/database/migrations/062_buchungs_kategorien.sql b/backend/src/database/migrations/062_buchungs_kategorien.sql new file mode 100644 index 0000000..de6b82e --- /dev/null +++ b/backend/src/database/migrations/062_buchungs_kategorien.sql @@ -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; diff --git a/backend/src/routes/buchungskategorie.routes.ts b/backend/src/routes/buchungskategorie.routes.ts new file mode 100644 index 0000000..c917a72 --- /dev/null +++ b/backend/src/routes/buchungskategorie.routes.ts @@ -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; diff --git a/backend/src/services/buchungskategorie.service.ts b/backend/src/services/buchungskategorie.service.ts new file mode 100644 index 0000000..713081e --- /dev/null +++ b/backend/src/services/buchungskategorie.service.ts @@ -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 }; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index eafd822..6ea33eb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,7 @@ import Fahrzeuge from './pages/Fahrzeuge'; import FahrzeugDetail from './pages/FahrzeugDetail'; import FahrzeugForm from './pages/FahrzeugForm'; import FahrzeugBuchungen from './pages/FahrzeugBuchungen'; +import BookingFormPage from './pages/BookingFormPage'; import Ausruestung from './pages/Ausruestung'; import AusruestungForm from './pages/AusruestungForm'; import AusruestungDetail from './pages/AusruestungDetail'; @@ -219,6 +220,22 @@ function App() { } /> + + + + } + /> + + + + } + /> (); + const navigate = useNavigate(); + const notification = useNotification(); + const isEdit = Boolean(id); + + const [form, setForm] = useState({ ...EMPTY_FORM }); + const [overrideOutOfService, setOverrideOutOfService] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(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 ( + + + + + + ); + } + + return ( + + + + navigate('/fahrzeugbuchungen')}> + + + + {isEdit ? 'Buchung bearbeiten' : 'Neue Buchung'} + + + + + {error && ( + + {error} + + )} + + + + Fahrzeug + + + + + setForm((f) => ({ ...f, titel: e.target.value })) + } + /> + + + setForm((f) => ({ ...f, beschreibung: e.target.value })) + } + /> + + { + 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" + /> + + { + if (form.ganztaegig) { + setForm((f) => ({ + ...f, + beginn: `${e.target.value}T00:00`, + })); + } else { + setForm((f) => ({ ...f, beginn: e.target.value })); + } + }} + InputLabelProps={{ shrink: true }} + /> + + { + 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 ? ( + + Ende muss nach dem Beginn liegen + + ) : ( + + {availability === null ? ( + + + + Verfügbarkeit wird geprüft... + + + ) : availability.available ? ( + } + label="Fahrzeug verfügbar" + color="success" + size="small" + /> + ) : availability.reason === 'out_of_service' ? ( + + } + 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" + /> + + setOverrideOutOfService(e.target.checked) + } + color="warning" + size="small" + /> + } + label={ + + Trotz Außer-Dienst-Status buchen + + } + sx={{ mt: 0.5 }} + /> + + ) : ( + } + label="Konflikt: bereits gebucht" + color="error" + size="small" + /> + )} + + ) + ) : ( + + Wähle Fahrzeug und Zeitraum für Verfügbarkeitsprüfung + + )} + + + Kategorie + + + + {isExtern && ( + <> + + setForm((f) => ({ ...f, kontaktPerson: e.target.value })) + } + /> + + setForm((f) => ({ + ...f, + kontaktTelefon: e.target.value, + })) + } + /> + + )} + + + + + + + + + + ); +} + +export default BookingFormPage; diff --git a/frontend/src/pages/FahrzeugBuchungen.tsx b/frontend/src/pages/FahrzeugBuchungen.tsx index 96b4bb0..ab4796a 100644 --- a/frontend/src/pages/FahrzeugBuchungen.tsx +++ b/frontend/src/pages/FahrzeugBuchungen.tsx @@ -1,92 +1,94 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState } from 'react'; import { Box, Container, Typography, Paper, + Card, + CardContent, + Button, + IconButton, + TextField, + Select, + MenuItem, + FormControl, + InputLabel, + CircularProgress, + Alert, + Chip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Tab, + Tabs, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, - Chip, - Button, - IconButton, - Dialog, - DialogTitle, - DialogContent, - DialogActions, - TextField, - Select, - MenuItem, - FormControl, - FormControlLabel, - InputLabel, - CircularProgress, - Alert, - Popover, + Checkbox, Stack, - Switch, Tooltip, } from '@mui/material'; import { Add, - ChevronLeft, - ChevronRight, - Today, + IosShare, ContentCopy, Cancel, Edit, - IosShare, - CheckCircle, - Warning, - Block, - Build, + Delete, + Save, + Close, + EventBusy, } from '@mui/icons-material'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { format, parseISO } from 'date-fns'; +import { de } from 'date-fns/locale'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import ChatAwareFab from '../components/shared/ChatAwareFab'; import { useAuth } from '../contexts/AuthContext'; import { usePermissionContext } from '../contexts/PermissionContext'; import { useNotification } from '../contexts/NotificationContext'; -import { bookingApi, fetchVehicles } from '../services/bookings'; +import { bookingApi, fetchVehicles, kategorieApi } from '../services/bookings'; import type { FahrzeugBuchungListItem, Fahrzeug, - CreateBuchungInput, BuchungsArt, - MaintenanceWindow, + BuchungsKategorie, } from '../types/booking.types'; import { BUCHUNGS_ART_LABELS, BUCHUNGS_ART_COLORS } from '../types/booking.types'; -import { - format, - startOfWeek, - endOfWeek, - addWeeks, - subWeeks, - eachDayOfInterval, - isToday, - parseISO, - isSameDay, - isWithinInterval, -} from 'date-fns'; -import { de } from 'date-fns/locale'; // --------------------------------------------------------------------------- -// Constants +// Helpers // --------------------------------------------------------------------------- -const EMPTY_FORM: CreateBuchungInput = { - fahrzeugId: '', - titel: '', - beschreibung: '', - beginn: '', - ende: '', - buchungsArt: 'intern', - kontaktPerson: '', - kontaktTelefon: '', - ganztaegig: false, -}; +function formatBookingDate(beginn: string, ende: string, ganztaegig: boolean): string { + try { + const b = parseISO(beginn); + const e = parseISO(ende); + if (ganztaegig) { + return `${format(b, 'dd.MM.yyyy')} – ${format(e, 'dd.MM.yyyy')} (Ganztägig)`; + } + return `${format(b, 'dd.MM.yyyy HH:mm')} – ${format(e, 'dd.MM.yyyy HH:mm')}`; + } catch { + return `${beginn} – ${ende}`; + } +} + +function getCategoryColor(buchungsArt: BuchungsArt, kategorien: BuchungsKategorie[]): string { + const kat = kategorien.find((k) => k.bezeichnung.toLowerCase() === buchungsArt); + if (kat) return kat.farbe; + return BUCHUNGS_ART_COLORS[buchungsArt] || '#757575'; +} + +function getCategoryLabel(buchungsArt: BuchungsArt, kategorien: BuchungsKategorie[]): string { + const kat = kategorien.find((k) => k.bezeichnung.toLowerCase() === buchungsArt); + if (kat) return kat.bezeichnung; + return BUCHUNGS_ART_LABELS[buchungsArt] || buchungsArt; +} // --------------------------------------------------------------------------- // Main Page @@ -96,247 +98,63 @@ function FahrzeugBuchungen() { const { user } = useAuth(); const { hasPermission } = usePermissionContext(); const notification = useNotification(); - const canCreate = hasPermission('kalender:manage_bookings'); - const canWrite = hasPermission('kalender:manage_bookings'); - const canCancelOwn = hasPermission('kalender:manage_bookings'); - const canChangeBuchungsArt = hasPermission('kalender:create'); + const navigate = useNavigate(); + const queryClient = useQueryClient(); - // ── Week navigation ──────────────────────────────────────────────────────── - const [currentWeekStart, setCurrentWeekStart] = useState(() => - startOfWeek(new Date(), { weekStartsOn: 1 }) - ); + const canCreate = hasPermission('kalender:create'); + const canManage = hasPermission('kalender:manage_bookings'); - const weekEnd = endOfWeek(currentWeekStart, { weekStartsOn: 1 }); + const [tabIndex, setTabIndex] = useState(0); - const weekDays = eachDayOfInterval({ start: currentWeekStart, end: weekEnd }); + // ── Filters ──────────────────────────────────────────────────────────────── + const today = new Date(); + const defaultFrom = format(today, 'yyyy-MM-dd'); + const defaultTo = format(new Date(today.getTime() + 90 * 86400000), 'yyyy-MM-dd'); + const [filterFrom, setFilterFrom] = useState(defaultFrom); + const [filterTo, setFilterTo] = useState(defaultTo); + const [filterVehicle, setFilterVehicle] = useState(''); + const [filterKategorie, setFilterKategorie] = useState(''); - const weekLabel = `KW ${format(currentWeekStart, 'w')} · ${format( - currentWeekStart, - 'dd.MM.' - )} – ${format(weekEnd, 'dd.MM.yyyy')}`; + // ── Data ─────────────────────────────────────────────────────────────────── + const { data: vehicles = [] } = useQuery({ + queryKey: ['vehicles'], + queryFn: fetchVehicles, + }); - // ── Data ────────────────────────────────────────────────────────────────── - const [vehicles, setVehicles] = useState([]); - const [bookings, setBookings] = useState([]); - const [maintenanceWindows, setMaintenanceWindows] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const { data: kategorien = [] } = useQuery({ + queryKey: ['buchungskategorien-all'], + queryFn: kategorieApi.getAll, + }); - const loadData = useCallback(async () => { - setLoading(true); - try { - const end = endOfWeek(currentWeekStart, { weekStartsOn: 1 }); - const [vehiclesData, calendarData] = await Promise.all([ - fetchVehicles(), - bookingApi.getCalendarRange(currentWeekStart, end), - ]); - setVehicles(vehiclesData); - setBookings(calendarData.bookings); - setMaintenanceWindows(calendarData.maintenanceWindows); - setError(null); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : 'Fehler beim Laden'; - setError(msg); - } finally { - setLoading(false); - } - }, [currentWeekStart]); + const { data: activeKategorien = [] } = useQuery({ + queryKey: ['buchungskategorien'], + queryFn: kategorieApi.getActive, + }); - useEffect(() => { - loadData(); - }, [loadData]); + const fromDate = filterFrom ? new Date(`${filterFrom}T00:00:00`) : new Date(); + const toDate = filterTo ? new Date(`${filterTo}T23:59:59`) : new Date(Date.now() + 90 * 86400000); - // ── Cell helper ─────────────────────────────────────────────────────────── - const getBookingsForCell = ( - vehicleId: string, - day: Date - ): FahrzeugBuchungListItem[] => { - return bookings.filter((b) => { - if (b.fahrzeug_id !== vehicleId || b.abgesagt) return false; - const start = parseISO(b.beginn); - const end = parseISO(b.ende); - return ( - isSameDay(start, day) || - isSameDay(end, day) || - (start < day && end > day) - ); - }); - }; + const { + data: calendarData, + isLoading, + isError, + error: loadError, + } = useQuery({ + queryKey: ['bookings-range', filterFrom, filterTo, filterVehicle], + queryFn: () => + bookingApi.getCalendarRange( + fromDate, + toDate, + filterVehicle || undefined + ), + }); - const isOutOfService = (vehicle: Fahrzeug, day: Date): boolean => { - // Check from maintenance windows (server-side filtered) - const mw = maintenanceWindows.find((w) => w.id === vehicle.id); - if (mw) { - try { - if ( - isWithinInterval(day, { - start: parseISO(mw.ausser_dienst_von), - end: parseISO(mw.ausser_dienst_bis), - }) - ) - return true; - } catch { - /* ignore parse errors */ - } - } - // Fallback to vehicle-level dates - if (!vehicle.ausser_dienst_von || !vehicle.ausser_dienst_bis) return false; - try { - return isWithinInterval(day, { - start: parseISO(vehicle.ausser_dienst_von), - end: parseISO(vehicle.ausser_dienst_bis), - }); - } catch { - return false; - } - }; + const bookings = (calendarData?.bookings || []) + .filter((b) => !b.abgesagt) + .filter((b) => !filterKategorie || b.buchungs_art === filterKategorie) + .sort((a, b) => new Date(a.beginn).getTime() - new Date(b.beginn).getTime()); - /** Get the maintenance tooltip text for an out-of-service cell */ - const getMaintenanceTooltip = (vehicle: Fahrzeug): string => { - const mw = maintenanceWindows.find((w) => w.id === vehicle.id); - const statusLabel = - (mw?.status ?? vehicle.status) === 'ausser_dienst_wartung' - ? 'Wartung' - : 'Schaden'; - const bemerkung = mw?.status_bemerkung ?? vehicle.status_bemerkung; - const von = mw?.ausser_dienst_von ?? vehicle.ausser_dienst_von; - const bis = mw?.ausser_dienst_bis ?? vehicle.ausser_dienst_bis; - - let tooltip = `Außer Dienst (${statusLabel})`; - if (bemerkung) tooltip += `: ${bemerkung}`; - if (von && bis) { - try { - tooltip += `\n${format(parseISO(von), 'dd.MM.yyyy')} – ${format(parseISO(bis), 'dd.MM.yyyy')}`; - } catch { - /* ignore */ - } - } - return tooltip; - }; - - // ── Create / Edit dialog ────────────────────────────────────────────────── - const [dialogOpen, setDialogOpen] = useState(false); - const [editingBooking, setEditingBooking] = - useState(null); - const [form, setForm] = useState({ ...EMPTY_FORM }); - const [dialogLoading, setDialogLoading] = useState(false); - const [dialogError, setDialogError] = useState(null); - const [overrideOutOfService, setOverrideOutOfService] = useState(false); - const [availability, setAvailability] = useState<{ - available: boolean; - reason?: string; - ausserDienstBis?: string; - } | null>(null); - - // Check availability whenever the 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, - editingBooking?.id - ) - .then((result) => { - if (!cancelled) setAvailability(result); - }) - .catch(() => { - if (!cancelled) setAvailability(null); - }); - }, 300); - return () => { - cancelled = true; - clearTimeout(timer); - }; - }, [form.fahrzeugId, form.beginn, form.ende, editingBooking?.id]); - - const openCreateDialog = () => { - setEditingBooking(null); - setForm({ ...EMPTY_FORM }); - setDialogError(null); - setAvailability(null); - setOverrideOutOfService(false); - setDialogOpen(true); - }; - - const handleCellClick = (vehicleId: string, day: Date) => { - if (!canCreate) return; - const dateStr = format(day, "yyyy-MM-dd'T'08:00"); - const dateEndStr = format(day, "yyyy-MM-dd'T'17:00"); - setEditingBooking(null); - setForm({ ...EMPTY_FORM, fahrzeugId: vehicleId, beginn: dateStr, ende: dateEndStr }); - setDialogError(null); - setAvailability(null); - setOverrideOutOfService(false); - setDialogOpen(true); - }; - - const handleSave = async () => { - setDialogLoading(true); - setDialogError(null); - try { - const beginnDate = new Date(form.beginn); - const endeDate = new Date(form.ende); - if (isNaN(beginnDate.getTime()) || isNaN(endeDate.getTime())) { - setDialogError('Ungültiges Datum. Bitte Beginn und Ende prüfen.'); - setDialogLoading(false); - return; - } - if (endeDate <= beginnDate) { - setDialogError('Ende muss nach dem Beginn liegen.'); - setDialogLoading(false); - return; - } - const payload: CreateBuchungInput = { - ...form, - beginn: beginnDate.toISOString(), - ende: endeDate.toISOString(), - ganztaegig: form.ganztaegig || false, - }; - if (editingBooking) { - await bookingApi.update(editingBooking.id, payload); - notification.showSuccess('Buchung aktualisiert'); - } else { - await bookingApi.create({ ...payload, ignoreOutOfService: overrideOutOfService } as any); - notification.showSuccess('Buchung erstellt'); - } - setDialogOpen(false); - loadData(); - } 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') { - setDialogError(axiosError?.response?.data?.message || 'Fahrzeug ist im gewählten Zeitraum außer Dienst'); - } else { - setDialogError(axiosError?.response?.data?.message || 'Fahrzeug ist im gewählten Zeitraum bereits gebucht'); - } - } else { - const msg = axiosError?.response?.data?.message || axiosError?.message || 'Fehler beim Speichern'; - setDialogError(msg); - } - } catch { - setDialogError(e instanceof Error ? e.message : 'Fehler beim Speichern'); - } - } finally { - setDialogLoading(false); - } - }; - - // ── Cancel dialog ───────────────────────────────────────────────────────── + // ── Cancel ───────────────────────────────────────────────────────────────── const [cancelId, setCancelId] = useState(null); const [cancelGrund, setCancelGrund] = useState(''); const [cancelLoading, setCancelLoading] = useState(false); @@ -348,62 +166,19 @@ function FahrzeugBuchungen() { await bookingApi.cancel(cancelId, cancelGrund); notification.showSuccess('Buchung storniert'); setCancelId(null); - setDetailAnchor(null); - setDetailBooking(null); - loadData(); + setCancelGrund(''); + queryClient.invalidateQueries({ queryKey: ['bookings-range'] }); } catch (e: unknown) { const axiosErr = e as { response?: { data?: { message?: string } }; message?: string }; - const msg = axiosErr?.response?.data?.message || (e instanceof Error ? e.message : 'Fehler beim Stornieren'); - notification.showError(msg); + notification.showError( + axiosErr?.response?.data?.message || (e instanceof Error ? e.message : 'Fehler beim Stornieren') + ); } finally { setCancelLoading(false); } }; - // ── Detail popover ──────────────────────────────────────────────────────── - const [detailAnchor, setDetailAnchor] = useState(null); - const [detailBooking, setDetailBooking] = - useState(null); - - const handleBookingClick = ( - e: React.MouseEvent, - booking: FahrzeugBuchungListItem - ) => { - e.stopPropagation(); - setDetailBooking(booking); - setDetailAnchor(e.currentTarget); - }; - - const handleOpenEdit = () => { - if (!detailBooking) return; - setEditingBooking(detailBooking); - setForm({ - fahrzeugId: detailBooking.fahrzeug_id, - titel: detailBooking.titel, - beschreibung: '', - beginn: format(parseISO(detailBooking.beginn), "yyyy-MM-dd'T'HH:mm"), - ende: format(parseISO(detailBooking.ende), "yyyy-MM-dd'T'HH:mm"), - buchungsArt: detailBooking.buchungs_art, - kontaktPerson: '', - kontaktTelefon: '', - ganztaegig: detailBooking.ganztaegig || false, - }); - setDialogError(null); - setAvailability(null); - setDialogOpen(true); - setDetailAnchor(null); - setDetailBooking(null); - }; - - const handleOpenCancel = () => { - if (!detailBooking) return; - setCancelId(detailBooking.id); - setCancelGrund(''); - setDetailAnchor(null); - setDetailBooking(null); - }; - - // ── iCal dialog ─────────────────────────────────────────────────────────── + // ── iCal ─────────────────────────────────────────────────────────────────── const [icalOpen, setIcalOpen] = useState(false); const [icalUrl, setIcalUrl] = useState(''); @@ -413,634 +188,405 @@ function FahrzeugBuchungen() { setIcalUrl(subscribeUrl); setIcalOpen(true); } catch (e: unknown) { - const msg = e instanceof Error ? e.message : 'Fehler beim Laden des Tokens'; - notification.showError(msg); + notification.showError(e instanceof Error ? e.message : 'Fehler beim Laden des Tokens'); } }; - // ── Derived date validity (used in availability section + save guard) ───── - 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 - ); + // ── Einstellungen: Categories management ─────────────────────────────────── + const [editRowId, setEditRowId] = useState(null); + const [editRowData, setEditRowData] = useState>({}); + const [newKatDialog, setNewKatDialog] = useState(false); + const [newKatForm, setNewKatForm] = useState({ bezeichnung: '', farbe: '#1976d2' }); - // ── Render ──────────────────────────────────────────────────────────────── + const createKatMutation = useMutation({ + mutationFn: (data: Omit) => kategorieApi.create(data), + onSuccess: () => { + notification.showSuccess('Kategorie erstellt'); + queryClient.invalidateQueries({ queryKey: ['buchungskategorien-all'] }); + queryClient.invalidateQueries({ queryKey: ['buchungskategorien'] }); + setNewKatDialog(false); + setNewKatForm({ bezeichnung: '', farbe: '#1976d2' }); + }, + onError: () => notification.showError('Fehler beim Erstellen'), + }); + + const updateKatMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: Partial }) => + kategorieApi.update(id, data), + onSuccess: () => { + notification.showSuccess('Kategorie aktualisiert'); + queryClient.invalidateQueries({ queryKey: ['buchungskategorien-all'] }); + queryClient.invalidateQueries({ queryKey: ['buchungskategorien'] }); + setEditRowId(null); + }, + onError: () => notification.showError('Fehler beim Aktualisieren'), + }); + + const deleteKatMutation = useMutation({ + mutationFn: (id: number) => kategorieApi.delete(id), + onSuccess: () => { + notification.showSuccess('Kategorie deaktiviert'); + queryClient.invalidateQueries({ queryKey: ['buchungskategorien-all'] }); + queryClient.invalidateQueries({ queryKey: ['buchungskategorien'] }); + }, + onError: () => notification.showError('Fehler beim Löschen'), + }); + + // ── Render ───────────────────────────────────────────────────────────────── return ( - {/* ── Header ── */} - + {/* Header */} + Fahrzeugbuchungen - - - {canCreate && ( - - )} - - - - {/* ── Week navigation ── */} - - setCurrentWeekStart((d) => subWeeks(d, 1))}> - - - - {weekLabel} - - setCurrentWeekStart((d) => addWeeks(d, 1))}> - - - - {/* ── Loading / error ── */} - {loading && ( - - - - )} - {!loading && error && ( - - {error} - + {/* Tabs */} + + setTabIndex(v)}> + + {canManage && } + + + + {/* ── Tab 0: Buchungen ─────────────────────────────────────────────── */} + {tabIndex === 0 && ( + <> + {/* Filters */} + + + setFilterFrom(e.target.value)} + InputLabelProps={{ shrink: true }} + sx={{ minWidth: 160 }} + /> + setFilterTo(e.target.value)} + InputLabelProps={{ shrink: true }} + sx={{ minWidth: 160 }} + /> + + Fahrzeug + + + + Kategorie + + + + + + {/* Loading */} + {isLoading && ( + + + + )} + + {/* Error */} + {isError && ( + + {loadError instanceof Error ? loadError.message : 'Fehler beim Laden'} + + )} + + {/* Bookings list */} + {!isLoading && !isError && bookings.length === 0 && ( + + + + Keine Buchungen vorhanden + + + )} + + {!isLoading && + !isError && + bookings.map((booking) => ( + + + + + + {booking.titel} + + + {booking.fahrzeug_name} + {booking.fahrzeug_kennzeichen + ? ` (${booking.fahrzeug_kennzeichen})` + : ''} + + + {formatBookingDate(booking.beginn, booking.ende, booking.ganztaegig)} + + + + {booking.gebucht_von_name && ( + + von {booking.gebucht_von_name} + + )} + + + + {(canManage || booking.gebucht_von === user?.id) && ( + + navigate(`/fahrzeugbuchungen/${booking.id}`)} + > + + + + )} + {(canManage || booking.gebucht_von === user?.id) && ( + + { + setCancelId(booking.id); + setCancelGrund(''); + }} + > + + + + )} + + + + + ))} + )} - {/* ── Timeline table ── */} - {!loading && !error && ( - - - - theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100' }}> - - Fahrzeug - - {weekDays.map((day) => ( - theme.palette.mode === 'dark' ? 'primary.900' : 'primary.50' : undefined, - }} - > - - {format(day, 'EEE', { locale: de })} - - - {format(day, 'dd.MM.')} - - - ))} - - - - {vehicles.map((vehicle) => ( - - - - {vehicle.bezeichnung} - - - {weekDays.map((day) => { - const cellBookings = getBookingsForCell(vehicle.id, day); - const oos = isOutOfService(vehicle, day); - const isFree = cellBookings.length === 0 && !oos; - return ( - - isFree ? handleCellClick(vehicle.id, day) : undefined - } - sx={{ - bgcolor: oos - ? (theme) => theme.palette.mode === 'dark' ? 'error.900' : 'error.50' - : isFree - ? (theme) => theme.palette.mode === 'dark' ? 'success.900' : 'success.50' - : undefined, - cursor: isFree && canCreate ? 'pointer' : oos ? 'not-allowed' : 'default', - '&:hover': isFree && canCreate - ? { bgcolor: (theme) => theme.palette.mode === 'dark' ? 'success.800' : 'success.100' } - : {}, - p: 0.5, - verticalAlign: 'top', - }} - > - {oos && ( - - {getMaintenanceTooltip(vehicle)} - + {/* ── Tab 1: Einstellungen ─────────────────────────────────────────── */} + {tabIndex === 1 && canManage && ( + + + Buchungskategorien + + + + +
+ + + Bezeichnung + Farbe + Sortierung + Aktiv + Aktionen + + + + {kategorien.map((kat) => { + const isEditing = editRowId === kat.id; + return ( + + + {isEditing ? ( + + setEditRowData((d) => ({ ...d, bezeichnung: e.target.value })) } - > - } - label="Außer Dienst" + /> + ) : ( + kat.bezeichnung + )} + + + {isEditing ? ( + + setEditRowData((d) => ({ ...d, farbe: e.target.value })) + } + style={{ width: 40, height: 28, border: 'none', cursor: 'pointer' }} + /> + ) : ( + + )} + + + {isEditing ? ( + + setEditRowData((d) => ({ + ...d, + sort_order: parseInt(e.target.value) || 0, + })) + } + sx={{ width: 80 }} + /> + ) : ( + kat.sort_order + )} + + + {isEditing ? ( + + setEditRowData((d) => ({ ...d, aktiv: e.target.checked })) + } + /> + ) : ( + + )} + + + {isEditing ? ( + + + updateKatMutation.mutate({ id: kat.id, data: editRowData }) + } + > + + + { + setEditRowId(null); + setEditRowData({}); + }} + > + + + + ) : ( + + { + setEditRowId(kat.id); + setEditRowData({ + bezeichnung: kat.bezeichnung, + farbe: kat.farbe, + sort_order: kat.sort_order, + aktiv: kat.aktiv, + }); + }} + > + + + - + onClick={() => deleteKatMutation.mutate(kat.id)} + > + + + )} - {cellBookings.map((b) => ( - - 12 - ? b.titel.slice(0, 12) + '…' - : b.titel - } - size="small" - onClick={(e) => handleBookingClick(e, b)} - sx={{ - bgcolor: BUCHUNGS_ART_COLORS[b.buchungs_art], - color: 'white', - fontSize: '0.65rem', - height: 20, - mb: 0.25, - display: 'flex', - width: '100%', - cursor: 'pointer', - }} - /> - - ))} - ); - })} - - ))} - {vehicles.length === 0 && ( - - - - Keine aktiven Fahrzeuge - - - - )} - -
-
- )} - - {/* ── Legend ── */} - {!loading && !error && ( - - - theme.palette.mode === 'dark' ? 'success.900' : 'success.50', - border: '1px solid', - borderColor: (theme) => theme.palette.mode === 'dark' ? 'success.700' : 'success.300', - borderRadius: 0.5, - }} - /> - Frei - - - theme.palette.mode === 'dark' ? 'error.900' : 'error.50', - border: '1px solid', - borderColor: (theme) => theme.palette.mode === 'dark' ? 'error.700' : 'error.300', - borderRadius: 0.5, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }} - > - - - Außer Dienst (Wartung/Schaden) - - {(Object.entries(BUCHUNGS_ART_LABELS) as [BuchungsArt, string][]).map( - ([art, label]) => ( - - - {label} - - ) - )} - + + ); + })} + {kategorien.length === 0 && ( + + + + Keine Kategorien vorhanden + + + + )} + + + + )} {/* ── FAB ── */} - {canCreate && ( + {canCreate && tabIndex === 0 && ( navigate('/fahrzeugbuchungen/neu')} > )} - {/* ── Booking detail popover ── */} - { - setDetailAnchor(null); - setDetailBooking(null); - }} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - transformOrigin={{ vertical: 'top', horizontal: 'center' }} - > - {detailBooking && ( - - - {detailBooking.titel} - - - - {detailBooking.ganztaegig - ? `${format(parseISO(detailBooking.beginn), 'dd.MM.yyyy')} – ${format(parseISO(detailBooking.ende), 'dd.MM.yyyy')} (Ganztägig)` - : `${format(parseISO(detailBooking.beginn), 'dd.MM.yyyy HH:mm')} – ${format(parseISO(detailBooking.ende), 'dd.MM.yyyy HH:mm')}` - } - - {detailBooking.gebucht_von_name && ( - - Von: {detailBooking.gebucht_von_name} - - )} - {(() => { - const mw = maintenanceWindows.find((w) => w.id === detailBooking.fahrzeug_id); - if (mw?.ausser_dienst_von && mw?.ausser_dienst_bis) { - const bookingStart = new Date(detailBooking.beginn); - const bookingEnd = new Date(detailBooking.ende); - const serviceStart = new Date(mw.ausser_dienst_von); - const serviceEnd = new Date(mw.ausser_dienst_bis); - if (bookingStart < serviceEnd && bookingEnd > serviceStart) { - return ( - - Fahrzeug außer Dienst: {format(serviceStart, 'dd.MM.')} – {format(serviceEnd, 'dd.MM.yyyy')} - - ); - } - } - return null; - })()} - {(canWrite || (canCancelOwn && detailBooking.gebucht_von === user?.id)) && ( - - {canWrite && ( - - )} - - - )} - - )} - - - {/* ── Create / Edit dialog ── */} - setDialogOpen(false)} - maxWidth="sm" - fullWidth - > - - {editingBooking ? 'Buchung bearbeiten' : 'Neue Buchung'} - - - {dialogError && ( - - {dialogError} - - )} - - - Fahrzeug - - - - - setForm((f) => ({ ...f, titel: e.target.value })) - } - /> - - - setForm((f) => ({ ...f, beschreibung: e.target.value })) - } - /> - - { - 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" - /> - - { - if (form.ganztaegig) { - setForm((f) => ({ ...f, beginn: `${e.target.value}T00:00` })); - } else { - setForm((f) => ({ ...f, beginn: e.target.value })); - } - }} - InputLabelProps={{ shrink: true }} - /> - - { - 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 ? ( - - Ende muss nach dem Beginn liegen - - ) : ( - - {availability === null ? ( - - - - Verfügbarkeit wird geprüft... - - - ) : availability.available ? ( - } - label="Fahrzeug verfügbar" - color="success" - size="small" - /> - ) : availability.reason === 'out_of_service' ? ( - - } - 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" - /> - setOverrideOutOfService(e.target.checked)} - color="warning" - size="small" - /> - } - label={ - - Trotz Außer-Dienst-Status buchen - - } - sx={{ mt: 0.5 }} - /> - - ) : ( - } - label="Konflikt: bereits gebucht" - color="error" - size="small" - /> - )} - - ) - ) : ( - - Wähle Fahrzeug und Zeitraum für Verfügbarkeitsprüfung - - )} - - - Buchungsart - - - - {form.buchungsArt === 'extern' && ( - <> - - setForm((f) => ({ ...f, kontaktPerson: e.target.value })) - } - /> - - setForm((f) => ({ - ...f, - kontaktTelefon: e.target.value, - })) - } - /> - - )} - - - - - - - - - - - - {/* ── Cancel dialog ── */} - @@ -1084,19 +625,13 @@ function FahrzeugBuchungen() { {/* ── iCal dialog ── */} - setIcalOpen(false)} - maxWidth="sm" - fullWidth - > + setIcalOpen(false)} maxWidth="sm" fullWidth> Kalender abonnieren - Abonniere den Fahrzeugbuchungskalender in deiner - Kalenderanwendung. Kopiere die URL und füge sie als neuen - Kalender (per URL) in Apple Kalender, Google Kalender oder - Outlook ein. + Abonniere den Fahrzeugbuchungskalender in deiner Kalenderanwendung. Kopiere die URL und + füge sie als neuen Kalender (per URL) in Apple Kalender, Google Kalender oder Outlook + ein. setIcalOpen(false)}>Schließen + + {/* ── New category dialog ── */} + setNewKatDialog(false)} + maxWidth="xs" + fullWidth + > + Neue Kategorie + + + + setNewKatForm((f) => ({ ...f, bezeichnung: e.target.value })) + } + /> + + + Farbe + + setNewKatForm((f) => ({ ...f, farbe: e.target.value }))} + style={{ width: 60, height: 36, border: 'none', cursor: 'pointer' }} + /> + + + + + + + +
); diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx index 918d9a0..0a7ce59 100644 --- a/frontend/src/pages/Kalender.tsx +++ b/frontend/src/pages/Kalender.tsx @@ -28,14 +28,12 @@ import { Skeleton, Stack, Switch, - Tab, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, - Tabs, TextField, Tooltip, Typography, @@ -51,9 +49,7 @@ import { ChevronRight, ContentCopy as CopyIcon, DeleteForever as DeleteForeverIcon, - DirectionsCar as CarIcon, Edit as EditIcon, - Event as EventIcon, FileDownload as FileDownloadIcon, FileUpload as FileUploadIcon, HelpOutline as UnknownIcon, @@ -65,18 +61,16 @@ import { ViewList as ListViewIcon, ViewDay as ViewDayIcon, ViewWeek as ViewWeekIcon, - Warning, } from '@mui/icons-material'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import ChatAwareFab from '../components/shared/ChatAwareFab'; -import { toGermanDateTime, fromGermanDate, fromGermanDateTime, isValidGermanDateTime } from '../utils/dateInput'; -import { useAuth } from '../contexts/AuthContext'; +import { toGermanDateTime, fromGermanDate, fromGermanDateTime } from '../utils/dateInput'; + import { usePermissionContext } from '../contexts/PermissionContext'; import { useNotification } from '../contexts/NotificationContext'; import { trainingApi } from '../services/training'; import { eventsApi } from '../services/events'; -import { bookingApi, fetchVehicles } from '../services/bookings'; import { configApi, type PdfSettings } from '../services/config'; import type { UebungListItem, @@ -90,13 +84,6 @@ import type { CreateVeranstaltungInput, WiederholungConfig, } from '../types/events.types'; -import type { - FahrzeugBuchungListItem, - Fahrzeug, - CreateBuchungInput, - BuchungsArt, -} from '../types/booking.types'; -import { BUCHUNGS_ART_LABELS, BUCHUNGS_ART_COLORS } from '../types/booking.types'; import { format as fnsFormat, startOfWeek, @@ -108,9 +95,7 @@ import { addWeeks, subWeeks, eachDayOfInterval, - isToday as fnsIsToday, parseISO, - isSameDay, } from 'date-fns'; import { de } from 'date-fns/locale'; @@ -149,17 +134,8 @@ const EMPTY_VERANSTALTUNG_FORM: CreateVeranstaltungInput = { anmeldung_bis: null, }; -const EMPTY_BOOKING_FORM: CreateBuchungInput = { - fahrzeugId: '', - titel: '', - beschreibung: '', - beginn: '', - ende: '', - buchungsArt: 'intern', - kontaktPerson: '', - kontaktTelefon: '', - ganztaegig: false, -}; + + // ────────────────────────────────────────────────────────────────────────────── // Helpers @@ -796,102 +772,6 @@ async function generatePdf( doc.save(filename); } -// ────────────────────────────────────────────────────────────────────────────── -// PDF Export — Fahrzeugbuchungen -// ────────────────────────────────────────────────────────────────────────────── - -async function generateBookingsPdf( - weekStart: Date, - weekEnd: Date, - bookings: FahrzeugBuchungListItem[], -) { - const { jsPDF } = await import('jspdf'); - const autoTable = (await import('jspdf-autotable')).default; - - const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' }); - - const startLabel = fnsFormat(weekStart, 'dd.MM.yyyy'); - const endLabel = fnsFormat(weekEnd, 'dd.MM.yyyy'); - const kwLabel = `KW ${fnsFormat(weekStart, 'w')}`; - - const pdfSettings = await fetchPdfSettings(); - - // Header bar - doc.setFillColor(183, 28, 28); // fire-red - doc.rect(0, 0, 297, 18, 'F'); - doc.setTextColor(255, 255, 255); - doc.setFontSize(14); - doc.setFont('helvetica', 'bold'); - doc.text(`Fahrzeugbuchungen — ${kwLabel} · ${startLabel} – ${endLabel}`, 10, 12); - - // Right side: logo and/or org name - const logoSize = 14; - const logoX = 297 - 4 - logoSize; - if (pdfSettings.pdf_logo) { - try { - const fmt = pdfSettings.pdf_logo.match(/^data:image\/(\w+);/)?.[1]?.toUpperCase() ?? 'PNG'; - doc.addImage(pdfSettings.pdf_logo, fmt === 'JPG' ? 'JPEG' : fmt, logoX, 2, logoSize, logoSize); - } catch { /* ignore invalid image */ } - } - if (pdfSettings.pdf_org_name) { - doc.setFontSize(9); - doc.setFont('helvetica', 'bold'); - doc.setTextColor(255, 255, 255); - const nameW = doc.getTextWidth(pdfSettings.pdf_org_name); - const nameX = (pdfSettings.pdf_logo ? logoX - 3 : 297 - 4) - nameW; - doc.text(pdfSettings.pdf_org_name, nameX, 12); - } else if (!pdfSettings.pdf_logo) { - doc.setFontSize(9); - doc.setFont('helvetica', 'normal'); - doc.text('Feuerwehr Rems', 250, 12); - } - - // Custom header text - let tableStartY = 22; - if (pdfSettings.pdf_header) { - tableStartY = renderMarkdownText(doc, pdfSettings.pdf_header, 10, 24, { fontSize: 9 }) + 2; - } - - const formatDt = (iso: string) => { - const d = new Date(iso); - return fnsFormat(d, 'dd.MM.yyyy HH:mm'); - }; - - const active = bookings.filter((b) => !b.abgesagt); - const rows = active.map((b) => [ - b.fahrzeug_name, - b.titel, - formatDt(b.beginn), - formatDt(b.ende), - BUCHUNGS_ART_LABELS[b.buchungs_art], - ]); - - autoTable(doc, { - head: [['Fahrzeug', 'Titel', 'Beginn', 'Ende', 'Art']], - body: rows, - startY: tableStartY, - headStyles: { fillColor: [183, 28, 28], textColor: 255, fontStyle: 'bold' }, - alternateRowStyles: { fillColor: [250, 235, 235] }, - margin: { left: 10, right: 10 }, - styles: { fontSize: 9, cellPadding: 2 }, - columnStyles: { - 0: { cellWidth: 45 }, - 1: { cellWidth: 90 }, - 2: { cellWidth: 38 }, - 3: { cellWidth: 38 }, - 4: { cellWidth: 35 }, - }, - didDrawPage: pdfSettings.pdf_footer - ? () => { - renderMarkdownText(doc, pdfSettings.pdf_footer, 10, doc.internal.pageSize.height - 12, { fontSize: 8 }); - } - : undefined, - }); - - const filename = `fahrzeugbuchungen_${fnsFormat(weekStart, 'yyyy-MM-dd')}.pdf`; - doc.save(filename); -} - // ────────────────────────────────────────────────────────────────────────────── // CSV Import Dialog // ────────────────────────────────────────────────────────────────────────────── @@ -1334,7 +1214,7 @@ function VeranstaltungFormDialog({ datum_von: editingEvent.datum_von, datum_bis: editingEvent.datum_bis, ganztaegig: editingEvent.ganztaegig, - zielgruppen: editingEvent.zielgruppen, + zielgruppen: editingEvent.zielgruppen ?? [], alle_gruppen: editingEvent.alle_gruppen, max_teilnehmer: null, anmeldung_erforderlich: editingEvent.anmeldung_erforderlich, @@ -1400,7 +1280,7 @@ function VeranstaltungFormDialog({ ...prev, kategorie_id: value as string | null, alle_gruppen: kat.alle_gruppen, - zielgruppen: kat.alle_gruppen ? [] : kat.zielgruppen, + zielgruppen: kat.alle_gruppen ? [] : (kat.zielgruppen ?? []), })); return; } @@ -1745,29 +1625,14 @@ function VeranstaltungFormDialog({ export default function Kalender() { const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const { user } = useAuth(); const { hasPermission } = usePermissionContext(); const notification = useNotification(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const canWriteEvents = hasPermission('kalender:create'); - const canWriteBookings = hasPermission('kalender:manage_bookings'); - const canCreateBookings = hasPermission('kalender:manage_bookings'); - // ── Tab ───────────────────────────────────────────────────────────────────── - const [activeTab, setActiveTab] = useState(() => { - const t = Number(searchParams.get('tab')); - return t >= 0 && t < 2 ? t : 0; - }); - - useEffect(() => { - const t = Number(searchParams.get('tab')); - if (t >= 0 && t < 2) setActiveTab(t); - }, [searchParams]); - - // ── Calendar tab state ─────────────────────────────────────────────────────── + // ── Calendar state ───────────────────────────────────────────────────────── const today = new Date(); const [viewMonth, setViewMonth] = useState({ year: today.getFullYear(), @@ -1799,42 +1664,10 @@ export default function Kalender() { const [deleteEventId, setDeleteEventId] = useState(null); const [deleteEventLoading, setDeleteEventLoading] = useState(false); - // ── Bookings tab state ─────────────────────────────────────────────────────── - const [currentWeekStart, setCurrentWeekStart] = useState(() => - startOfWeek(new Date(), { weekStartsOn: 1 }) - ); - const weekEnd = endOfWeek(currentWeekStart, { weekStartsOn: 1 }); - const weekDays = eachDayOfInterval({ start: currentWeekStart, end: weekEnd }); - const weekLabel = `KW ${fnsFormat(currentWeekStart, 'w')} · ${fnsFormat(currentWeekStart, 'dd.MM.')} – ${fnsFormat(weekEnd, 'dd.MM.yyyy')}`; - - const [vehicles, setVehicles] = useState([]); - const [bookings, setBookings] = useState([]); - const [bookingsLoading, setBookingsLoading] = useState(false); - const [bookingsError, setBookingsError] = useState(null); - - // Booking detail popover - const [detailAnchor, setDetailAnchor] = useState(null); - const [detailBooking, setDetailBooking] = useState(null); - - // Booking form dialog - const [bookingDialogOpen, setBookingDialogOpen] = useState(false); - const [editingBooking, setEditingBooking] = useState(null); - const [bookingForm, setBookingForm] = useState({ ...EMPTY_BOOKING_FORM }); - const [bookingDialogLoading, setBookingDialogLoading] = useState(false); - const [bookingDialogError, setBookingDialogError] = useState(null); - const [availability, setAvailability] = useState(null); - - // Cancel booking - const [cancelBookingId, setCancelBookingId] = useState(null); - const [cancelBookingGrund, setCancelBookingGrund] = useState(''); - const [cancelBookingLoading, setCancelBookingLoading] = useState(false); - // iCal subscription const [icalEventOpen, setIcalEventOpen] = useState(false); const [icalEventUrl, setIcalEventUrl] = useState(''); - const [icalBookingOpen, setIcalBookingOpen] = useState(false); const [csvImportOpen, setCsvImportOpen] = useState(false); - const [icalBookingUrl, setIcalBookingUrl] = useState(''); // ── Data loading ───────────────────────────────────────────────────────────── @@ -1876,28 +1709,6 @@ export default function Kalender() { loadCalendarData(); }, [loadCalendarData]); - const loadBookingsData = useCallback(async () => { - setBookingsLoading(true); - setBookingsError(null); - try { - const end = endOfWeek(currentWeekStart, { weekStartsOn: 1 }); - const [vehiclesData, calendarData] = await Promise.all([ - fetchVehicles(), - bookingApi.getCalendarRange(currentWeekStart, end), - ]); - setVehicles(vehiclesData); - setBookings(calendarData.bookings); - } catch (e: unknown) { - setBookingsError(e instanceof Error ? e.message : 'Fehler beim Laden'); - } finally { - setBookingsLoading(false); - } - }, [currentWeekStart]); - - useEffect(() => { - loadBookingsData(); - }, [loadBookingsData]); - // ── Calendar tab helpers ───────────────────────────────────────────────────── const handlePrev = () => { @@ -2049,154 +1860,6 @@ export default function Kalender() { } }; - // ── Booking helpers ────────────────────────────────────────────────────────── - - const getBookingsForCell = (vehicleId: string, day: Date): FahrzeugBuchungListItem[] => - bookings.filter((b) => { - if (b.fahrzeug_id !== vehicleId || b.abgesagt) return false; - const start = parseISO(b.beginn); - const end = parseISO(b.ende); - return isSameDay(start, day) || isSameDay(end, day) || (start < day && end > day); - }); - - const openBookingCreate = () => { - setEditingBooking(null); - setBookingForm({ ...EMPTY_BOOKING_FORM }); - setBookingDialogError(null); - setAvailability(null); - setBookingDialogOpen(true); - }; - - const handleCellClick = (vehicleId: string, day: Date) => { - if (!canCreateBookings) return; - setEditingBooking(null); - setBookingForm({ - ...EMPTY_BOOKING_FORM, - fahrzeugId: vehicleId, - beginn: toGermanDateTime(fnsFormat(day, "yyyy-MM-dd'T'08:00")), - ende: toGermanDateTime(fnsFormat(day, "yyyy-MM-dd'T'17:00")), - }); - setBookingDialogError(null); - setAvailability(null); - setBookingDialogOpen(true); - }; - - const handleOpenBookingEdit = () => { - if (!detailBooking) return; - setEditingBooking(detailBooking); - setBookingForm({ - fahrzeugId: detailBooking.fahrzeug_id, - titel: detailBooking.titel, - beschreibung: '', - beginn: toGermanDateTime(fnsFormat(parseISO(detailBooking.beginn as unknown as string), "yyyy-MM-dd'T'HH:mm")), - ende: toGermanDateTime(fnsFormat(parseISO(detailBooking.ende as unknown as string), "yyyy-MM-dd'T'HH:mm")), - buchungsArt: detailBooking.buchungs_art, - kontaktPerson: '', - kontaktTelefon: '', - ganztaegig: (detailBooking as any).ganztaegig || false, - }); - setBookingDialogError(null); - setAvailability(null); - setBookingDialogOpen(true); - setDetailAnchor(null); - setDetailBooking(null); - }; - - // Availability check - useEffect(() => { - if (!bookingForm.fahrzeugId || !bookingForm.beginn || !bookingForm.ende) { - setAvailability(null); - return; - } - let cancelled = false; - const beginnIso = fromGermanDateTime(bookingForm.beginn) || bookingForm.beginn; - const endeIso = fromGermanDateTime(bookingForm.ende) || bookingForm.ende; - bookingApi - .checkAvailability( - bookingForm.fahrzeugId, - new Date(beginnIso), - new Date(endeIso) - ) - .then(({ available }) => { - if (!cancelled) setAvailability(available); - }) - .catch(() => { - if (!cancelled) setAvailability(null); - }); - return () => { cancelled = true; }; - }, [bookingForm.fahrzeugId, bookingForm.beginn, bookingForm.ende]); - - const handleBookingSave = async () => { - if (!isValidGermanDateTime(bookingForm.beginn)) { - setBookingDialogError('Ungültiges Beginn-Datum (Format: TT.MM.JJJJ HH:MM)'); - return; - } - if (!isValidGermanDateTime(bookingForm.ende)) { - setBookingDialogError('Ungültiges Ende-Datum (Format: TT.MM.JJJJ HH:MM)'); - return; - } - const beginnIso = fromGermanDateTime(bookingForm.beginn)!; - const endeIso = fromGermanDateTime(bookingForm.ende)!; - const beginnDate = new Date(beginnIso); - const endeDate = new Date(endeIso); - if (isNaN(beginnDate.getTime())) { - setBookingDialogError('Ungültiges Beginn-Datum'); - return; - } - if (isNaN(endeDate.getTime())) { - setBookingDialogError('Ungültiges Ende-Datum'); - return; - } - if (endeDate <= beginnDate) { - setBookingDialogError('Ende muss nach dem Beginn liegen'); - return; - } - setBookingDialogLoading(true); - setBookingDialogError(null); - try { - const payload: CreateBuchungInput = { - ...bookingForm, - beginn: beginnDate.toISOString(), - ende: endeDate.toISOString(), - }; - if (editingBooking) { - await bookingApi.update(editingBooking.id, payload); - notification.showSuccess('Buchung aktualisiert'); - } else { - await bookingApi.create(payload); - notification.showSuccess('Buchung erstellt'); - } - setBookingDialogOpen(false); - loadBookingsData(); - } catch (e: unknown) { - const axiosError = e as { response?: { status?: number }; message?: string }; - if (axiosError?.response?.status === 409) { - setBookingDialogError('Fahrzeug ist im gewählten Zeitraum bereits gebucht'); - } else { - setBookingDialogError(axiosError?.message || 'Fehler beim Speichern'); - } - } finally { - setBookingDialogLoading(false); - } - }; - - const handleBookingCancel = async () => { - if (!cancelBookingId) return; - setCancelBookingLoading(true); - try { - await bookingApi.cancel(cancelBookingId, cancelBookingGrund); - notification.showSuccess('Buchung storniert'); - setCancelBookingId(null); - setDetailAnchor(null); - setDetailBooking(null); - loadBookingsData(); - } catch (e: unknown) { - notification.showError(e instanceof Error ? e.message : 'Fehler beim Stornieren'); - } finally { - setCancelBookingLoading(false); - } - }; - const handleIcalEventOpen = async () => { try { const { subscribeUrl } = await eventsApi.getCalendarToken(); @@ -2208,17 +1871,6 @@ export default function Kalender() { } }; - const handleIcalBookingOpen = async () => { - try { - const { subscribeUrl } = await bookingApi.getCalendarToken(); - setIcalBookingUrl(subscribeUrl); - setIcalBookingOpen(true); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : 'Fehler beim Laden des Tokens'; - notification.showError(msg); - } - }; - // ── Render ─────────────────────────────────────────────────────────────────── return ( @@ -2232,20 +1884,7 @@ export default function Kalender() { - {/* Tabs */} - { setActiveTab(v); navigate(`/kalender?tab=${v}`, { replace: true }); }} - sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }} - variant="scrollable" - scrollButtons="auto" - > - } iconPosition="start" label="Dienste & Veranstaltungen" /> - } iconPosition="start" label="Fahrzeugbuchungen" /> - - - {/* ── TAB 0: Calendar ───────────────────────────────────────────── */} - {activeTab === 0 && ( + {/* ── Calendar ───────────────────────────────────────────── */} {/* Controls row */} - )} - - {/* ── TAB 1: Fahrzeugbuchungen ──────────────────────────────────── */} - {activeTab === 1 && ( - - {/* Week navigation */} - - - setCurrentWeekStart((d) => subWeeks(d, 1))} - > - - - - {weekLabel} - - setCurrentWeekStart((d) => addWeeks(d, 1))} - > - - - - - - {canCreateBookings && ( - - )} - - {/* iCal subscribe */} - - - {/* PDF Export */} - - generateBookingsPdf(currentWeekStart, weekEnd, bookings)} - > - - - - - - {bookingsLoading && ( - - - - )} - {!bookingsLoading && bookingsError && ( - - {bookingsError} - - )} - - {!bookingsLoading && !bookingsError && ( - <> - - - - theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100' }}> - - Fahrzeug - - {weekDays.map((day) => ( - theme.palette.mode === 'dark' ? 'primary.900' : 'primary.50' : undefined, - }} - > - - {fnsFormat(day, 'EEE', { locale: de })} - - - {fnsFormat(day, 'dd.MM.')} - - - ))} - - - - {vehicles.map((vehicle) => ( - - - - {vehicle.bezeichnung} - - - {weekDays.map((day) => { - const cellBookings = getBookingsForCell(vehicle.id, day); - const isFree = cellBookings.length === 0; - return ( - - isFree ? handleCellClick(vehicle.id, day) : undefined - } - sx={{ - bgcolor: isFree ? 'success.50' : undefined, - cursor: isFree && canCreateBookings ? 'pointer' : 'default', - '&:hover': - isFree && canCreateBookings - ? { bgcolor: 'success.100' } - : {}, - p: 0.5, - verticalAlign: 'top', - }} - > - {cellBookings.map((b) => ( - - 12 - ? b.titel.slice(0, 12) + '…' - : b.titel - } - size="small" - onClick={(e) => { - e.stopPropagation(); - setDetailBooking(b); - setDetailAnchor(e.currentTarget); - }} - sx={{ - bgcolor: BUCHUNGS_ART_COLORS[b.buchungs_art], - color: 'white', - fontSize: '0.65rem', - height: 20, - mb: 0.25, - display: 'flex', - width: '100%', - cursor: 'pointer', - }} - /> - - ))} - - ); - })} - - ))} - {vehicles.length === 0 && ( - - - - Keine aktiven Fahrzeuge - - - - )} - -
-
- - {/* Legend */} - - - - Frei - - {(Object.entries(BUCHUNGS_ART_LABELS) as [BuchungsArt, string][]).map( - ([art, label]) => ( - - - {label} - - ) - )} - - - )} - - {/* FAB */} - {canCreateBookings && ( - - - - )} - - {/* Booking detail popover */} - { setDetailAnchor(null); setDetailBooking(null); }} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - transformOrigin={{ vertical: 'top', horizontal: 'center' }} - > - {detailBooking && ( - - - {detailBooking.titel} - - - - {(detailBooking as any).ganztaegig - ? `${fnsFormat(parseISO(detailBooking.beginn), 'dd.MM.yyyy')} – ${fnsFormat(parseISO(detailBooking.ende), 'dd.MM.yyyy')} (Ganztägig)` - : `${fnsFormat(parseISO(detailBooking.beginn), 'dd.MM.yyyy HH:mm')} – ${fnsFormat(parseISO(detailBooking.ende), 'dd.MM.yyyy HH:mm')}` - } - - {detailBooking.gebucht_von_name && ( - - Von: {detailBooking.gebucht_von_name} - - )} - {(canWriteBookings || detailBooking.gebucht_von === user?.id) && ( - - {canWriteBookings && ( - - )} - - - )} - - )} - - - {/* Booking create/edit dialog */} - setBookingDialogOpen(false)} - maxWidth="sm" - fullWidth - > - - {editingBooking ? 'Buchung bearbeiten' : 'Neue Buchung'} - - - {bookingDialogError && ( - - {bookingDialogError} - - )} - - - Fahrzeug - - - - - setBookingForm((f) => ({ ...f, titel: e.target.value })) - } - /> - - { - const checked = e.target.checked; - setBookingForm((f) => ({ ...f, ganztaegig: checked })); - }} - /> - } - label="Ganztägig" - /> - - - setBookingForm((f) => ({ ...f, beginn: e.target.value })) - } - InputLabelProps={{ shrink: true }} - /> - - - setBookingForm((f) => ({ ...f, ende: e.target.value })) - } - InputLabelProps={{ shrink: true }} - /> - - {availability !== null && ( - : } - label={availability ? 'Fahrzeug verfügbar' : 'Konflikt: bereits gebucht'} - color={availability ? 'success' : 'error'} - size="small" - sx={{ alignSelf: 'flex-start' }} - /> - )} - - - Buchungsart - - - - {bookingForm.buchungsArt === 'extern' && ( - <> - - setBookingForm((f) => ({ ...f, kontaktPerson: e.target.value })) - } - /> - - setBookingForm((f) => ({ ...f, kontaktTelefon: e.target.value })) - } - /> - - )} - - - - - - - - - {/* Cancel booking dialog */} - setCancelBookingId(null)} - maxWidth="xs" - fullWidth - > - Buchung stornieren - - setCancelBookingGrund(e.target.value)} - sx={{ mt: 1 }} - helperText={`${cancelBookingGrund.length}/1000 (min. 5 Zeichen)`} - inputProps={{ maxLength: 1000 }} - /> - - - - - - - - {/* iCal Booking subscription dialog */} - setIcalBookingOpen(false)} maxWidth="sm" fullWidth> - Kalender abonnieren - - - Abonniere den Fahrzeugbuchungskalender in deiner Kalenderanwendung. Kopiere die URL und füge sie als neuen - Kalender (per URL) in Apple Kalender, Google Kalender oder Outlook ein. - - { - navigator.clipboard.writeText(icalBookingUrl); - notification.showSuccess('URL kopiert!'); - }} - > - - - ), - }} - /> - - - - - - - - )} diff --git a/frontend/src/services/bookings.ts b/frontend/src/services/bookings.ts index c0df62e..7375c12 100644 --- a/frontend/src/services/bookings.ts +++ b/frontend/src/services/bookings.ts @@ -5,6 +5,7 @@ import type { Fahrzeug, CreateBuchungInput, MaintenanceWindow, + BuchungsKategorie, } from '../types/booking.types'; // --------------------------------------------------------------------------- @@ -117,6 +118,17 @@ export const bookingApi = { }, }; +// --------------------------------------------------------------------------- +// Booking categories +// --------------------------------------------------------------------------- +export const kategorieApi = { + getAll: () => api.get('/api/buchungskategorien').then((r) => r.data), + getActive: () => api.get('/api/buchungskategorien/active').then((r) => r.data), + create: (data: Omit) => api.post('/api/buchungskategorien', data).then((r) => r.data), + update: (id: number, data: Partial) => api.patch(`/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) // --------------------------------------------------------------------------- diff --git a/frontend/src/types/booking.types.ts b/frontend/src/types/booking.types.ts index cac2c72..e4276c8 100644 --- a/frontend/src/types/booking.types.ts +++ b/frontend/src/types/booking.types.ts @@ -63,6 +63,14 @@ export interface MaintenanceWindow { ausser_dienst_bis: string; } +export interface BuchungsKategorie { + id: number; + bezeichnung: string; + farbe: string; + aktiv: boolean; + sort_order: number; +} + export interface CreateBuchungInput { fahrzeugId: string; titel: string;