diff --git a/backend/src/controllers/booking.controller.ts b/backend/src/controllers/booking.controller.ts index 3591316..c514544 100644 --- a/backend/src/controllers/booking.controller.ts +++ b/backend/src/controllers/booking.controller.ts @@ -2,6 +2,7 @@ import { Request, Response } from 'express'; import { ZodError } from 'zod'; import bookingService from '../services/booking.service'; import vehicleService from '../services/vehicle.service'; +import pool from '../config/database'; import { permissionService } from '../services/permission.service'; import { CreateBuchungSchema, @@ -43,6 +44,22 @@ function handleConflictError(res: Response, err: Error): boolean { // --------------------------------------------------------------------------- class BookingController { + /** + * GET /api/bookings/vehicles + * Lightweight vehicle list for the booking form (no fahrzeuge:view needed). + */ + async getVehiclesForBooking(_req: Request, res: Response): Promise { + try { + const result = await pool.query( + 'SELECT id, bezeichnung, amtliches_kennzeichen FROM fahrzeuge ORDER BY bezeichnung' + ); + res.json({ success: true, data: result.rows }); + } catch (err) { + logger.error('Failed to fetch vehicles for booking', err); + res.status(500).json({ success: false, message: 'Fehler beim Laden der Fahrzeuge' }); + } + } + /** * GET /api/bookings/calendar?from=&to=&fahrzeugId= * Returns all non-cancelled bookings overlapping the given date range. diff --git a/backend/src/routes/booking.routes.ts b/backend/src/routes/booking.routes.ts index fd0b5c7..d94bbb0 100644 --- a/backend/src/routes/booking.routes.ts +++ b/backend/src/routes/booking.routes.ts @@ -11,6 +11,7 @@ router.get('/calendar.ics', optionalAuth, bookingController.getIcalExport.bind(b // ── Read-only (all authenticated users) ────────────────────────────────────── +router.get('/vehicles', authenticate, bookingController.getVehiclesForBooking.bind(bookingController)); router.get('/calendar', authenticate, bookingController.getCalendarRange.bind(bookingController)); router.get('/upcoming', authenticate, bookingController.getUpcoming.bind(bookingController)); router.get('/availability', authenticate, bookingController.checkAvailability.bind(bookingController)); diff --git a/frontend/src/pages/BookingFormPage.tsx b/frontend/src/pages/BookingFormPage.tsx index 93bbcb5..178637c 100644 --- a/frontend/src/pages/BookingFormPage.tsx +++ b/frontend/src/pages/BookingFormPage.tsx @@ -2,7 +2,6 @@ import { useState, useEffect } from 'react'; import { Box, Typography, - Paper, Button, TextField, Select, @@ -16,6 +15,11 @@ import { IconButton, Chip, Stack, + Card, + CardContent, + CardHeader, + Container, + Grid, } from '@mui/material'; import { ArrowBack, @@ -237,7 +241,8 @@ function BookingFormPage() { {!isFeatureEnabled('fahrzeugbuchungen') ? ( ) : ( - + + {/* Page header */} navigate('/fahrzeugbuchungen')}> @@ -247,280 +252,300 @@ function BookingFormPage() { - - {error && ( - - {error} - - )} + {error && ( + + {error} + + )} - - - Fahrzeug - - + + {/* Card 1: Fahrzeug & Zeitraum */} + + + + + + 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`, - }; + + + { + 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 }} + /> + + + + { + const checked = e.target.checked; + setForm((f) => { + if (checked && f.beginn) { + const dateStr = f.beginn.split('T')[0]; + return { + ...f, + ganztaegig: true, + beginn: `${dateStr}T00:00`, + ende: f.ende + ? `${f.ende.split('T')[0]}T23:59` + : `${dateStr}T23:59`, + }; + } + return { ...f, ganztaegig: checked }; + }); + }} + /> + } + label="Ganztägig" /> - } - label="Ganztägig" - /> - { - 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" + {/* Availability indicator */} + {form.fahrzeugId && form.beginn && form.ende ? ( + !formDatesValid ? ( + - )} - - ) - ) : ( - - Wähle Fahrzeug und Zeitraum für Verfügbarkeitsprüfung - - )} - - - Kategorie - - + ) : 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 + + )} + + + - {isExtern && ( - <> + {/* Card 2: Details */} + + + + - setForm((f) => ({ ...f, kontaktPerson: e.target.value })) + setForm((f) => ({ ...f, titel: e.target.value })) } /> + - setForm((f) => ({ - ...f, - kontaktTelefon: e.target.value, - })) + setForm((f) => ({ ...f, beschreibung: e.target.value })) } /> - - )} - - + + Kategorie + + + + {isExtern && ( + <> + + setForm((f) => ({ ...f, kontaktPerson: e.target.value })) + } + /> + + setForm((f) => ({ + ...f, + kontaktTelefon: e.target.value, + })) + } + /> + + )} + + + + + {/* Actions */} + - - + + )} ); diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx index a45cf61..3b8251b 100644 --- a/frontend/src/pages/Kalender.tsx +++ b/frontend/src/pages/Kalender.tsx @@ -24,16 +24,20 @@ import { MenuItem, Paper, Popover, + Radio, + RadioGroup, Select, Skeleton, Stack, Switch, + Tab, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, + Tabs, TextField, Tooltip, Typography, @@ -55,14 +59,14 @@ import { HelpOutline as UnknownIcon, IosShare, PictureAsPdf as PdfIcon, + Settings as SettingsIcon, Star as StarIcon, Today as TodayIcon, - Tune, ViewList as ListViewIcon, ViewDay as ViewDayIcon, ViewWeek as ViewWeekIcon, } from '@mui/icons-material'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import ServiceModePage from '../components/shared/ServiceModePage'; import ChatAwareFab from '../components/shared/ChatAwareFab'; @@ -1620,6 +1624,155 @@ function VeranstaltungFormDialog({ ); } +// ────────────────────────────────────────────────────────────────────────────── +// Settings Tab (Kategorien CRUD) +// ────────────────────────────────────────────────────────────────────────────── + +interface SettingsTabProps { + kategorien: VeranstaltungKategorie[]; + onKategorienChange: (k: VeranstaltungKategorie[]) => void; +} + +function SettingsTab({ kategorien, onKategorienChange }: SettingsTabProps) { + const notification = useNotification(); + const [editingKat, setEditingKat] = useState(null); + const [newKatOpen, setNewKatOpen] = useState(false); + const [newKatForm, setNewKatForm] = useState({ name: '', farbe: '#1976d2', beschreibung: '' }); + const [saving, setSaving] = useState(false); + + const reload = async () => { + const kat = await eventsApi.getKategorien(); + onKategorienChange(kat); + }; + + const handleCreate = async () => { + if (!newKatForm.name.trim()) return; + setSaving(true); + try { + await eventsApi.createKategorie({ name: newKatForm.name.trim(), farbe: newKatForm.farbe, beschreibung: newKatForm.beschreibung || undefined }); + notification.showSuccess('Kategorie erstellt'); + setNewKatOpen(false); + setNewKatForm({ name: '', farbe: '#1976d2', beschreibung: '' }); + await reload(); + } catch { notification.showError('Fehler beim Erstellen'); } + finally { setSaving(false); } + }; + + const handleUpdate = async () => { + if (!editingKat) return; + setSaving(true); + try { + await eventsApi.updateKategorie(editingKat.id, { name: editingKat.name, farbe: editingKat.farbe, beschreibung: editingKat.beschreibung ?? undefined }); + notification.showSuccess('Kategorie gespeichert'); + setEditingKat(null); + await reload(); + } catch { notification.showError('Fehler beim Speichern'); } + finally { setSaving(false); } + }; + + const handleDelete = async (id: string) => { + try { + await eventsApi.deleteKategorie(id); + notification.showSuccess('Kategorie gelöscht'); + await reload(); + } catch { notification.showError('Fehler beim Löschen'); } + }; + + return ( + + + Veranstaltungskategorien + + + + + + + + Farbe + Name + Beschreibung + Aktionen + + + + {kategorien.map((k) => ( + + + + + {k.name} + {k.beschreibung ?? '—'} + + setEditingKat({ ...k })}> + + + handleDelete(k.id)}> + + + + + ))} + {kategorien.length === 0 && ( + + + Noch keine Kategorien vorhanden + + + )} + +
+
+ + {/* Edit dialog */} + setEditingKat(null)} maxWidth="xs" fullWidth> + Kategorie bearbeiten + + + setEditingKat((k) => k ? { ...k, name: e.target.value } : k)} fullWidth required /> + + Farbe + setEditingKat((k) => k ? { ...k, farbe: e.target.value } : k)} style={{ width: 40, height: 36, border: 'none', cursor: 'pointer', borderRadius: 4 }} /> + {editingKat?.farbe} + + setEditingKat((k) => k ? { ...k, beschreibung: e.target.value || null } : k)} fullWidth multiline rows={2} /> + + + + + + + + + {/* New category dialog */} + setNewKatOpen(false)} maxWidth="xs" fullWidth> + Neue Kategorie + + + setNewKatForm((f) => ({ ...f, name: e.target.value }))} fullWidth required /> + + Farbe + setNewKatForm((f) => ({ ...f, farbe: e.target.value }))} style={{ width: 40, height: 36, border: 'none', cursor: 'pointer', borderRadius: 4 }} /> + {newKatForm.farbe} + + setNewKatForm((f) => ({ ...f, beschreibung: e.target.value }))} fullWidth multiline rows={2} /> + + + + + + + +
+ ); +} + // ────────────────────────────────────────────────────────────────────────────── // Main Kalender Page // ────────────────────────────────────────────────────────────────────────────── @@ -1633,6 +1786,11 @@ export default function Kalender() { const canWriteEvents = hasPermission('kalender:create'); + // ── Tab / search params ─────────────────────────────────────────────────── + const [searchParams, setSearchParams] = useSearchParams(); + const activeTab = Number(searchParams.get('tab') ?? 0); + const setActiveTab = (n: number) => setSearchParams({ tab: String(n) }); + // ── Calendar state ───────────────────────────────────────────────────────── const today = new Date(); const [viewMonth, setViewMonth] = useState({ @@ -1664,6 +1822,8 @@ export default function Kalender() { const [cancelEventLoading, setCancelEventLoading] = useState(false); const [deleteEventId, setDeleteEventId] = useState(null); const [deleteEventLoading, setDeleteEventLoading] = useState(false); + const [deleteMode, setDeleteMode] = useState<'single' | 'future' | 'all'>('all'); + const [editScopeEvent, setEditScopeEvent] = useState(null); // iCal subscription const [icalEventOpen, setIcalEventOpen] = useState(false); @@ -1850,7 +2010,7 @@ export default function Kalender() { if (!deleteEventId) return; setDeleteEventLoading(true); try { - await eventsApi.deleteEvent(deleteEventId); + await eventsApi.deleteEvent(deleteEventId, deleteMode); notification.showSuccess('Veranstaltung wurde endgültig gelöscht'); setDeleteEventId(null); loadCalendarData(); @@ -1861,6 +2021,20 @@ export default function Kalender() { } }; + const handleOpenDeleteDialog = useCallback((id: string) => { + setDeleteMode('all'); + setDeleteEventId(id); + }, []); + + const handleEventEdit = useCallback(async (ev: VeranstaltungListItem) => { + if (ev.wiederholung_parent_id) { + setEditScopeEvent(ev); + return; + } + setVeranstEditing(ev); + setVeranstFormOpen(true); + }, []); + const handleIcalEventOpen = async () => { try { const { subscribeUrl } = await eventsApi.getCalendarToken(); @@ -1889,6 +2063,15 @@ export default function Kalender() { + {canWriteEvents ? ( + setActiveTab(v)} sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}> + + } iconPosition="start" label="Einstellungen" value={1} /> + + ) : null} + + {activeTab === 0 && ( + <> {/* ── Calendar ───────────────────────────────────────────── */} {/* Controls row */} @@ -1941,18 +2124,6 @@ export default function Kalender() { - {/* Kategorien verwalten */} - {canWriteEvents && ( - - navigate('/veranstaltungen/kategorien')} - > - - - - )} - {/* PDF Export — available in all views */} navigate(`/training/${id}`)} - onEventEdit={(ev) => { - setVeranstEditing(ev); - setVeranstFormOpen(true); - }} + onEventEdit={handleEventEdit} onEventCancel={(id) => { setCancelEventId(id); setCancelEventGrund(''); }} - onEventDelete={(id) => setDeleteEventId(id)} + onEventDelete={handleOpenDeleteDialog} /> @@ -2294,11 +2462,8 @@ export default function Kalender() { canWriteEvents={canWriteEvents} onClose={() => setPopoverAnchor(null)} onTrainingClick={(id) => navigate(`/training/${id}`)} - onEventEdit={(ev) => { - setVeranstEditing(ev); - setVeranstFormOpen(true); - }} - onEventDelete={(id) => setDeleteEventId(id)} + onEventEdit={handleEventEdit} + onEventDelete={handleOpenDeleteDialog} /> {/* Veranstaltung Form Dialog */} @@ -2347,30 +2512,111 @@ export default function Kalender() { {/* Endgültig löschen Dialog */} + {(() => { + const deleteEvent = veranstaltungen.find(e => e.id === deleteEventId) ?? null; + const isRecurring = Boolean(deleteEvent?.wiederholung || deleteEvent?.wiederholung_parent_id); + return ( + setDeleteEventId(null)} + maxWidth="xs" + fullWidth + > + Veranstaltung endgültig löschen? + + + Diese Aktion kann nicht rückgängig gemacht werden. + + {isRecurring && ( + setDeleteMode(e.target.value as 'single' | 'future' | 'all')} + sx={{ mt: 2 }} + > + } label="Nur diesen Termin" /> + } label="Diesen und alle folgenden Termine" /> + } label="Alle Termine der Serie" /> + + )} + + + + + + + ); + })()} + + {/* Edit scope dialog for recurring event instances */} setDeleteEventId(null)} + open={Boolean(editScopeEvent)} + onClose={() => setEditScopeEvent(null)} maxWidth="xs" fullWidth > - Veranstaltung endgültig löschen? + Wiederkehrenden Termin bearbeiten - Diese Veranstaltung wird endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden. + Welche Termine möchtest du bearbeiten? - - + @@ -2414,6 +2660,12 @@ export default function Kalender() { + + )} + + {activeTab === 1 && canWriteEvents && ( + + )} diff --git a/frontend/src/services/bookings.ts b/frontend/src/services/bookings.ts index b61a17e..2a55831 100644 --- a/frontend/src/services/bookings.ts +++ b/frontend/src/services/bookings.ts @@ -134,6 +134,6 @@ export const kategorieApi = { // --------------------------------------------------------------------------- export function fetchVehicles(): Promise { return api - .get>('/api/vehicles') + .get>('/api/bookings/vehicles') .then((r) => r.data.data); }