From bc6d09200af3c6b6a786b6b9dd8c825d51090b0b Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Fri, 13 Mar 2026 19:23:39 +0100 Subject: [PATCH] update --- backend/src/controllers/booking.controller.ts | 29 +++- backend/src/controllers/vehicle.controller.ts | 36 ++++- .../029_vehicle_ausser_dienst_zeitraum.sql | 127 ++++++++++++++++++ backend/src/models/booking.model.ts | 2 +- backend/src/models/vehicle.model.ts | 24 +++- backend/src/services/booking.service.ts | 45 ++++++- backend/src/services/vehicle.service.ts | 105 +++++++++++++-- frontend/src/pages/FahrzeugBuchungen.tsx | 97 ++++++++++--- frontend/src/pages/FahrzeugDetail.tsx | 109 +++++++++++++-- frontend/src/pages/Fahrzeuge.tsx | 32 +++-- frontend/src/services/bookings.ts | 4 +- frontend/src/services/vehicles.ts | 9 +- frontend/src/types/booking.types.ts | 6 +- frontend/src/types/vehicle.types.ts | 32 ++++- sync/src/scraper.ts | 27 +++- 15 files changed, 610 insertions(+), 74 deletions(-) create mode 100644 backend/src/database/migrations/029_vehicle_ausser_dienst_zeitraum.sql diff --git a/backend/src/controllers/booking.controller.ts b/backend/src/controllers/booking.controller.ts index 3cbdef2..c3474d5 100644 --- a/backend/src/controllers/booking.controller.ts +++ b/backend/src/controllers/booking.controller.ts @@ -25,8 +25,12 @@ function handleZodError(res: Response, err: ZodError): void { } function handleConflictError(res: Response, err: Error): boolean { + if (err.message?.includes('außer Dienst')) { + res.status(409).json({ success: false, message: err.message, reason: 'out_of_service' }); + return true; + } if (err.message?.includes('bereits gebucht')) { - res.status(409).json({ success: false, message: err.message }); + res.status(409).json({ success: false, message: err.message, reason: 'booking_conflict' }); return true; } return false; @@ -88,10 +92,27 @@ class BookingController { .json({ success: false, message: 'fahrzeugId, from und to sind erforderlich' }); return; } + const beginn = new Date(from as string); + const ende = new Date(to as string); + + const outOfService = await bookingService.checkOutOfServiceConflict( + fahrzeugId as string, beginn, ende + ); + if (outOfService) { + res.json({ + success: true, + data: { + available: false, + reason: 'out_of_service', + ausserDienstVon: outOfService.ausser_dienst_von.toISOString(), + ausserDienstBis: outOfService.ausser_dienst_bis.toISOString(), + }, + }); + return; + } + const hasConflict = await bookingService.checkConflict( - fahrzeugId as string, - new Date(from as string), - new Date(to as string) + fahrzeugId as string, beginn, ende ); res.json({ success: true, data: { available: !hasConflict } }); } catch (error) { diff --git a/backend/src/controllers/vehicle.controller.ts b/backend/src/controllers/vehicle.controller.ts index 97f8e6b..3fa9a50 100644 --- a/backend/src/controllers/vehicle.controller.ts +++ b/backend/src/controllers/vehicle.controller.ts @@ -16,7 +16,6 @@ const FahrzeugStatusEnum = z.enum([ FahrzeugStatus.Einsatzbereit, FahrzeugStatus.AusserDienstWartung, FahrzeugStatus.AusserDienstSchaden, - FahrzeugStatus.InLehrgang, ]); const isoDate = z.string().regex( @@ -64,10 +63,27 @@ const UpdateFahrzeugSchema = z.object({ naechste_wartung_am: isoDate.nullable().optional(), }); +const isoDatetime = z.string().datetime({ offset: true, message: 'Erwartet ISO-8601 Datum mit Zeitzone' }); + const UpdateStatusSchema = z.object({ - status: FahrzeugStatusEnum, - bemerkung: z.string().max(500).optional().default(''), -}); + status: FahrzeugStatusEnum, + bemerkung: z.string().max(500).optional().default(''), + ausserDienstVon: isoDatetime.optional(), + ausserDienstBis: isoDatetime.optional(), +}).refine( + (d) => { + const isAusserDienst = d.status === FahrzeugStatus.AusserDienstWartung || d.status === FahrzeugStatus.AusserDienstSchaden; + if (!isAusserDienst) return true; + return !!d.ausserDienstVon && !!d.ausserDienstBis; + }, + { message: 'Außer-Dienst-Zeitraum (von + bis) ist bei diesem Status erforderlich', path: ['ausserDienstVon'] } +).refine( + (d) => { + if (!d.ausserDienstVon || !d.ausserDienstBis) return true; + return new Date(d.ausserDienstBis) > new Date(d.ausserDienstVon); + }, + { message: 'Enddatum muss nach Startdatum liegen', path: ['ausserDienstBis'] } +); const CreateWartungslogSchema = z.object({ datum: isoDate, @@ -211,10 +227,16 @@ class VehicleController { return; } const io = req.app.get('io') ?? undefined; - await vehicleService.updateVehicleStatus( - id, parsed.data.status, parsed.data.bemerkung, getUserId(req), io + const result = await vehicleService.updateVehicleStatus( + id, + parsed.data.status, + parsed.data.bemerkung, + getUserId(req), + io, + parsed.data.ausserDienstVon ? new Date(parsed.data.ausserDienstVon) : null, + parsed.data.ausserDienstBis ? new Date(parsed.data.ausserDienstBis) : null, ); - res.status(200).json({ success: true, message: 'Status aktualisiert' }); + res.status(200).json({ success: true, data: result }); } catch (error: any) { if (error?.message === 'Vehicle not found') { res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' }); diff --git a/backend/src/database/migrations/029_vehicle_ausser_dienst_zeitraum.sql b/backend/src/database/migrations/029_vehicle_ausser_dienst_zeitraum.sql new file mode 100644 index 0000000..a830a4d --- /dev/null +++ b/backend/src/database/migrations/029_vehicle_ausser_dienst_zeitraum.sql @@ -0,0 +1,127 @@ +-- ============================================================================= +-- Migration 029: Vehicle Out-of-Service Timeframe + Lehrgang Booking Type +-- +-- 1. Add ausser_dienst_von / ausser_dienst_bis columns to fahrzeuge +-- 2. Add 'lehrgang' value to fahrzeug_buchung_art enum +-- 3. Remove 'in_lehrgang' from vehicle status CHECK constraint +-- 4. Refresh fahrzeuge_mit_pruefstatus view to include new columns +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- 1. Add out-of-service timeframe columns +-- ----------------------------------------------------------------------------- +ALTER TABLE fahrzeuge + ADD COLUMN IF NOT EXISTS ausser_dienst_von TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS ausser_dienst_bis TIMESTAMPTZ; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE table_name = 'fahrzeuge' + AND constraint_name = 'chk_ausser_dienst_zeitraum' + ) THEN + ALTER TABLE fahrzeuge + ADD CONSTRAINT chk_ausser_dienst_zeitraum + CHECK ( + ausser_dienst_von IS NULL + OR ausser_dienst_bis IS NULL + OR ausser_dienst_bis > ausser_dienst_von + ); + END IF; +END +$$; + +CREATE INDEX IF NOT EXISTS idx_fahrzeuge_ausser_dienst_zeitraum + ON fahrzeuge(id, ausser_dienst_von, ausser_dienst_bis) + WHERE ausser_dienst_von IS NOT NULL; + +-- ----------------------------------------------------------------------------- +-- 2. Add 'lehrgang' to fahrzeug_buchung_art enum +-- ----------------------------------------------------------------------------- +DO $$ +BEGIN + ALTER TYPE fahrzeug_buchung_art ADD VALUE IF NOT EXISTS 'lehrgang'; +EXCEPTION + WHEN others THEN + IF SQLERRM NOT LIKE '%already exists%' THEN + RAISE; + END IF; +END +$$; + +-- ----------------------------------------------------------------------------- +-- 3. Remove 'in_lehrgang' from vehicle status CHECK +-- ----------------------------------------------------------------------------- + +-- Migrate existing in_lehrgang rows first +UPDATE fahrzeuge SET status = 'einsatzbereit' WHERE status = 'in_lehrgang'; + +-- Drop old check constraint (created in migration 005 as fahrzeuge_status_check) +ALTER TABLE fahrzeuge DROP CONSTRAINT IF EXISTS fahrzeuge_status_check; + +-- Re-add with only 3 valid values +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE table_name = 'fahrzeuge' + AND constraint_name = 'chk_fahrzeuge_status' + ) THEN + ALTER TABLE fahrzeuge + ADD CONSTRAINT chk_fahrzeuge_status + CHECK (status IN ( + 'einsatzbereit', + 'ausser_dienst_wartung', + 'ausser_dienst_schaden' + )); + END IF; +END +$$; + +-- ----------------------------------------------------------------------------- +-- 4. Refresh fahrzeuge_mit_pruefstatus view to include new columns +-- ----------------------------------------------------------------------------- +DROP VIEW IF EXISTS fahrzeuge_mit_pruefstatus; + +CREATE OR REPLACE VIEW fahrzeuge_mit_pruefstatus AS +SELECT + f.id, + f.bezeichnung, + f.kurzname, + f.amtliches_kennzeichen, + f.fahrgestellnummer, + f.baujahr, + f.hersteller, + f.typ_schluessel, + f.besatzung_soll, + f.status, + f.status_bemerkung, + f.ausser_dienst_von, + f.ausser_dienst_bis, + f.standort, + f.bild_url, + f.created_at, + f.updated_at, + f.paragraph57a_faellig_am, + CASE + WHEN f.paragraph57a_faellig_am IS NOT NULL + THEN f.paragraph57a_faellig_am::date - CURRENT_DATE + ELSE NULL + END AS paragraph57a_tage_bis_faelligkeit, + f.naechste_wartung_am, + CASE + WHEN f.naechste_wartung_am IS NOT NULL + THEN f.naechste_wartung_am::date - CURRENT_DATE + ELSE NULL + END AS wartung_tage_bis_faelligkeit, + LEAST( + CASE WHEN f.paragraph57a_faellig_am IS NOT NULL + THEN f.paragraph57a_faellig_am::date - CURRENT_DATE + ELSE NULL END, + CASE WHEN f.naechste_wartung_am IS NOT NULL + THEN f.naechste_wartung_am::date - CURRENT_DATE + ELSE NULL END + ) AS naechste_pruefung_tage +FROM fahrzeuge f +WHERE f.deleted_at IS NULL; diff --git a/backend/src/models/booking.model.ts b/backend/src/models/booking.model.ts index 296f6ce..ea3ed97 100644 --- a/backend/src/models/booking.model.ts +++ b/backend/src/models/booking.model.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; // Enums // --------------------------------------------------------------------------- -export const BUCHUNGS_ARTEN = ['intern', 'extern', 'wartung', 'reservierung', 'sonstiges'] as const; +export const BUCHUNGS_ARTEN = ['intern', 'extern', 'wartung', 'reservierung', 'sonstiges', 'lehrgang'] as const; export type BuchungsArt = (typeof BUCHUNGS_ARTEN)[number]; // --------------------------------------------------------------------------- diff --git a/backend/src/models/vehicle.model.ts b/backend/src/models/vehicle.model.ts index df63202..ea5fd6b 100644 --- a/backend/src/models/vehicle.model.ts +++ b/backend/src/models/vehicle.model.ts @@ -8,14 +8,12 @@ export enum FahrzeugStatus { Einsatzbereit = 'einsatzbereit', AusserDienstWartung = 'ausser_dienst_wartung', AusserDienstSchaden = 'ausser_dienst_schaden', - InLehrgang = 'in_lehrgang', } export const FahrzeugStatusLabel: Record = { [FahrzeugStatus.Einsatzbereit]: 'Einsatzbereit', [FahrzeugStatus.AusserDienstWartung]: 'Außer Dienst (Wartung)', [FahrzeugStatus.AusserDienstSchaden]: 'Außer Dienst (Schaden)', - [FahrzeugStatus.InLehrgang]: 'In Lehrgang', }; export type WartungslogArt = @@ -25,6 +23,12 @@ export type WartungslogArt = // ── Core Entities ───────────────────────────────────────────────────────────── +export interface AktiverLehrgang { + titel: string; + beginn: Date; + ende: Date; +} + export interface Fahrzeug { id: string; bezeichnung: string; @@ -37,12 +41,15 @@ export interface Fahrzeug { besatzung_soll: string | null; status: FahrzeugStatus; status_bemerkung: string | null; + ausser_dienst_von: Date | null; + ausser_dienst_bis: Date | null; standort: string; bild_url: string | null; paragraph57a_faellig_am: Date | null; naechste_wartung_am: Date | null; created_at: Date; updated_at: Date; + aktiver_lehrgang?: AktiverLehrgang | null; } export interface FahrzeugWartungslog { @@ -71,12 +78,15 @@ export interface FahrzeugListItem { besatzung_soll: string | null; status: FahrzeugStatus; status_bemerkung: string | null; + ausser_dienst_von: Date | null; + ausser_dienst_bis: Date | null; bild_url: string | null; paragraph57a_faellig_am: Date | null; paragraph57a_tage_bis_faelligkeit: number | null; naechste_wartung_am: Date | null; wartung_tage_bis_faelligkeit: number | null; naechste_pruefung_tage: number | null; + aktiver_lehrgang?: AktiverLehrgang | null; } // ── Detail View ─────────────────────────────────────────────────────────────── @@ -94,11 +104,19 @@ export interface VehicleStats { total: number; einsatzbereit: number; ausserDienst: number; - inLehrgang: number; + inLehrgang: number; // derived from active lehrgang bookings inspectionsDue: number; inspectionsOverdue: number; } +export interface OverlappingBooking { + id: string; + titel: string; + beginn: Date; + ende: Date; + gebucht_von_name: string; +} + // ── Inspection Alert ────────────────────────────────────────────────────────── export type InspectionAlertType = '57a' | 'wartung'; diff --git a/backend/src/services/booking.service.ts b/backend/src/services/booking.service.ts index 8dfc1e1..746363f 100644 --- a/backend/src/services/booking.service.ts +++ b/backend/src/services/booking.service.ts @@ -141,6 +141,35 @@ class BookingService { return rowToBuchung(rows[0]); } + /** + * Checks whether a vehicle is out of service during the given interval. + * Returns the out-of-service period if there IS a conflict, null otherwise. + */ + async checkOutOfServiceConflict( + fahrzeugId: string, + beginn: Date, + ende: Date + ): Promise<{ ausser_dienst_von: Date; ausser_dienst_bis: Date } | null> { + const query = ` + SELECT ausser_dienst_von, ausser_dienst_bis + FROM fahrzeuge + WHERE id = $1 + AND deleted_at IS NULL + AND status IN ('ausser_dienst_wartung', 'ausser_dienst_schaden') + AND ausser_dienst_von IS NOT NULL + AND ausser_dienst_bis IS NOT NULL + AND ($2::timestamptz, $3::timestamptz) OVERLAPS (ausser_dienst_von, ausser_dienst_bis) + LIMIT 1 + `; + + const { rows } = await pool.query(query, [fahrzeugId, beginn, ende]); + if (rows.length === 0) return null; + return { + ausser_dienst_von: new Date(rows[0].ausser_dienst_von), + ausser_dienst_bis: new Date(rows[0].ausser_dienst_bis), + }; + } + /** * Checks whether a vehicle is already booked for the given interval. * Returns true if there IS a conflict. @@ -171,8 +200,13 @@ class BookingService { return rows.length > 0; } - /** Creates a new booking. Throws if the vehicle has a conflicting booking. */ + /** Creates a new booking. Throws if the vehicle has a conflicting booking or is out of service. */ async create(data: CreateBuchungData, userId: string): Promise { + const outOfService = await this.checkOutOfServiceConflict(data.fahrzeugId, data.beginn, data.ende); + if (outOfService) { + throw new Error('Fahrzeug ist im gewählten Zeitraum außer Dienst'); + } + const hasConflict = await this.checkConflict( data.fahrzeugId, data.beginn, @@ -225,6 +259,15 @@ class BookingService { data.fahrzeugId != null || data.beginn != null || data.ende != null; if (timingChanged) { + const outOfService = await this.checkOutOfServiceConflict( + effectiveFahrzeugId, + effectiveBeginn, + effectiveEnde + ); + if (outOfService) { + throw new Error('Fahrzeug ist im gewählten Zeitraum außer Dienst'); + } + const hasConflict = await this.checkConflict( effectiveFahrzeugId, effectiveBeginn, diff --git a/backend/src/services/vehicle.service.ts b/backend/src/services/vehicle.service.ts index b271ff5..b22fc77 100644 --- a/backend/src/services/vehicle.service.ts +++ b/backend/src/services/vehicle.service.ts @@ -11,6 +11,7 @@ import { FahrzeugStatus, VehicleStats, InspectionAlert, + OverlappingBooking, } from '../models/vehicle.model'; class VehicleService { @@ -24,12 +25,30 @@ class VehicleService { SELECT id, bezeichnung, kurzname, amtliches_kennzeichen, baujahr, hersteller, besatzung_soll, status, status_bemerkung, + ausser_dienst_von, ausser_dienst_bis, bild_url, paragraph57a_faellig_am, paragraph57a_tage_bis_faelligkeit, naechste_wartung_am, wartung_tage_bis_faelligkeit, naechste_pruefung_tage FROM fahrzeuge_mit_pruefstatus ORDER BY bezeichnung ASC `); + // Fetch active lehrgang bookings for all vehicles in one query + const lehrgangs = await pool.query(` + SELECT DISTINCT ON (b.fahrzeug_id) + b.fahrzeug_id, b.titel, b.beginn, b.ende + FROM fahrzeug_buchungen b + WHERE b.buchungs_art = 'lehrgang' + AND b.abgesagt = FALSE + AND b.beginn <= NOW() + AND b.ende >= NOW() + ORDER BY b.fahrzeug_id, b.beginn ASC + `); + + const lehrgangsMap = new Map(); + for (const r of lehrgangs.rows) { + lehrgangsMap.set(r.fahrzeug_id, { titel: r.titel, beginn: new Date(r.beginn), ende: new Date(r.ende) }); + } + return result.rows.map((row) => ({ ...row, paragraph57a_tage_bis_faelligkeit: row.paragraph57a_tage_bis_faelligkeit != null @@ -38,6 +57,7 @@ class VehicleService { ? parseInt(row.wartung_tage_bis_faelligkeit, 10) : null, naechste_pruefung_tage: row.naechste_pruefung_tage != null ? parseInt(row.naechste_pruefung_tage, 10) : null, + aktiver_lehrgang: lehrgangsMap.get(row.id) ?? null, })) as FahrzeugListItem[]; } catch (error) { logger.error('VehicleService.getAllVehicles failed', { error }); @@ -79,6 +99,8 @@ class VehicleService { besatzung_soll: row.besatzung_soll, status: row.status as FahrzeugStatus, status_bemerkung: row.status_bemerkung, + ausser_dienst_von: row.ausser_dienst_von ?? null, + ausser_dienst_bis: row.ausser_dienst_bis ?? null, standort: row.standort, bild_url: row.bild_url, paragraph57a_faellig_am: row.paragraph57a_faellig_am ?? null, @@ -97,6 +119,23 @@ class VehicleService { })) as FahrzeugWartungslog[], }; + // Fetch active lehrgang booking if any + const lehrgang = await pool.query(` + SELECT titel, beginn, ende + FROM fahrzeug_buchungen + WHERE fahrzeug_id = $1 + AND buchungs_art = 'lehrgang' + AND abgesagt = FALSE + AND beginn <= NOW() + AND ende >= NOW() + ORDER BY beginn ASC + LIMIT 1 + `, [id]); + + vehicle.aktiver_lehrgang = lehrgang.rows.length > 0 + ? { titel: lehrgang.rows[0].titel, beginn: lehrgang.rows[0].beginn, ende: lehrgang.rows[0].ende } + : null; + return vehicle; } catch (error) { logger.error('VehicleService.getVehicleById failed', { error, id }); @@ -224,8 +263,10 @@ class VehicleService { status: FahrzeugStatus, bemerkung: string, updatedBy: string, - io?: any - ): Promise { + io?: any, + ausserDienstVon?: Date | null, + ausserDienstBis?: Date | null, + ): Promise<{ overlappingBookings: OverlappingBooking[] }> { const client = await pool.connect(); try { await client.query('BEGIN'); @@ -242,26 +283,56 @@ class VehicleService { const { bezeichnung, status: oldStatus } = oldResult.rows[0]; + const isAusserDienst = status === FahrzeugStatus.AusserDienstWartung || status === FahrzeugStatus.AusserDienstSchaden; + await client.query( - `UPDATE fahrzeuge SET status = $1, status_bemerkung = $2 WHERE id = $3`, - [status, bemerkung || null, id] + `UPDATE fahrzeuge + SET status = $1, status_bemerkung = $2, + ausser_dienst_von = $3, ausser_dienst_bis = $4 + WHERE id = $5`, + [ + status, + bemerkung || null, + isAusserDienst ? (ausserDienstVon ?? null) : null, + isAusserDienst ? (ausserDienstBis ?? null) : null, + id, + ] ); await client.query('COMMIT'); logger.info('Vehicle status updated', { id, from: oldStatus, to: status, by: updatedBy }); + // Find bookings that overlap with the out-of-service period + let overlappingBookings: OverlappingBooking[] = []; + if (isAusserDienst && ausserDienstVon && ausserDienstBis) { + const overlap = await pool.query( + `SELECT b.id, b.titel, b.beginn, b.ende, u.name AS gebucht_von_name + FROM fahrzeug_buchungen b + JOIN users u ON u.id = b.gebucht_von + WHERE b.fahrzeug_id = $1 + AND b.abgesagt = FALSE + AND ($2::timestamptz, $3::timestamptz) OVERLAPS (b.beginn, b.ende)`, + [id, ausserDienstVon, ausserDienstBis] + ); + overlappingBookings = overlap.rows; + } + if (io) { io.emit('vehicle:statusChanged', { - vehicleId: id, + vehicleId: id, bezeichnung, oldStatus, - newStatus: status, - bemerkung: bemerkung || null, + newStatus: status, + bemerkung: bemerkung || null, + ausserDienstVon: isAusserDienst ? ausserDienstVon ?? null : null, + ausserDienstBis: isAusserDienst ? ausserDienstBis ?? null : null, updatedBy, - timestamp: new Date().toISOString(), + timestamp: new Date().toISOString(), }); } + + return { overlappingBookings }; } catch (error) { await client.query('ROLLBACK').catch(() => {}); logger.error('VehicleService.updateVehicleStatus failed', { error, id }); @@ -347,12 +418,21 @@ class VehicleService { COUNT(*) FILTER (WHERE status = 'einsatzbereit') AS einsatzbereit, COUNT(*) FILTER ( WHERE status IN ('ausser_dienst_wartung','ausser_dienst_schaden') - ) AS ausser_dienst, - COUNT(*) FILTER (WHERE status = 'in_lehrgang') AS in_lehrgang + ) AS ausser_dienst FROM fahrzeuge WHERE deleted_at IS NULL `); + // Count vehicles with an active lehrgang booking + const lehrgangsResult = await pool.query(` + SELECT COUNT(DISTINCT b.fahrzeug_id) AS in_lehrgang + FROM fahrzeug_buchungen b + WHERE b.buchungs_art = 'lehrgang' + AND b.abgesagt = FALSE + AND b.beginn <= NOW() + AND b.ende >= NOW() + `); + const alertResult = await pool.query(` SELECT COUNT(*) FILTER ( @@ -373,14 +453,15 @@ class VehicleService { WHERE deleted_at IS NULL `); - const totals = totalsResult.rows[0] ?? { total: '0', einsatzbereit: '0', ausser_dienst: '0', in_lehrgang: '0' }; + const totals = totalsResult.rows[0] ?? { total: '0', einsatzbereit: '0', ausser_dienst: '0' }; + const lehrgangs = lehrgangsResult.rows[0] ?? { in_lehrgang: '0' }; const alerts = alertResult.rows[0] ?? { inspections_due: '0', inspections_overdue: '0' }; return { total: parseInt(totals.total ?? '0', 10), einsatzbereit: parseInt(totals.einsatzbereit ?? '0', 10), ausserDienst: parseInt(totals.ausser_dienst ?? '0', 10), - inLehrgang: parseInt(totals.in_lehrgang ?? '0', 10), + inLehrgang: parseInt(lehrgangs.in_lehrgang ?? '0', 10), inspectionsDue: parseInt(alerts.inspections_due ?? '0', 10), inspectionsOverdue: parseInt(alerts.inspections_overdue ?? '0', 10), }; diff --git a/frontend/src/pages/FahrzeugBuchungen.tsx b/frontend/src/pages/FahrzeugBuchungen.tsx index 6f8ab0a..20b5100 100644 --- a/frontend/src/pages/FahrzeugBuchungen.tsx +++ b/frontend/src/pages/FahrzeugBuchungen.tsx @@ -40,6 +40,7 @@ import { IosShare, CheckCircle, Warning, + Block, } from '@mui/icons-material'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import { useAuth } from '../contexts/AuthContext'; @@ -62,6 +63,7 @@ import { isToday, parseISO, isSameDay, + isWithinInterval, } from 'date-fns'; import { de } from 'date-fns/locale'; @@ -156,6 +158,18 @@ function FahrzeugBuchungen() { }); }; + const isOutOfService = (vehicle: Fahrzeug, day: Date): boolean => { + 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; + } + }; + // ── Create / Edit dialog ────────────────────────────────────────────────── const [dialogOpen, setDialogOpen] = useState(false); const [editingBooking, setEditingBooking] = @@ -163,7 +177,11 @@ function FahrzeugBuchungen() { const [form, setForm] = useState({ ...EMPTY_FORM }); const [dialogLoading, setDialogLoading] = useState(false); const [dialogError, setDialogError] = useState(null); - const [availability, setAvailability] = useState(null); + const [availability, setAvailability] = useState<{ + available: boolean; + reason?: string; + ausserDienstBis?: string; + } | null>(null); // Check availability whenever the relevant form fields change useEffect(() => { @@ -178,8 +196,8 @@ function FahrzeugBuchungen() { new Date(form.beginn), new Date(form.ende) ) - .then(({ available }) => { - if (!cancelled) setAvailability(available); + .then((result) => { + if (!cancelled) setAvailability(result); }) .catch(() => { if (!cancelled) setAvailability(null); @@ -227,9 +245,14 @@ function FahrzeugBuchungen() { setDialogOpen(false); loadData(); } catch (e: unknown) { - const axiosError = e as { response?: { status?: number }; message?: string }; + const axiosError = e as { response?: { status?: number; data?: { message?: string; reason?: string } }; message?: string }; if (axiosError?.response?.status === 409) { - setDialogError('Fahrzeug ist im gewählten Zeitraum bereits gebucht'); + 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('Fahrzeug ist im gewählten Zeitraum bereits gebucht'); + } } else { setDialogError(axiosError?.message || 'Fehler beim Speichern'); } @@ -437,7 +460,8 @@ function FahrzeugBuchungen() { {weekDays.map((day) => { const cellBookings = getBookingsForCell(vehicle.id, day); - const isFree = cellBookings.length === 0; + const oos = isOutOfService(vehicle, day); + const isFree = cellBookings.length === 0 && !oos; return ( theme.palette.mode === 'dark' ? 'success.900' : 'success.50' : undefined, - cursor: isFree && canCreate ? 'pointer' : 'default', + 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' } : {}, @@ -454,6 +482,18 @@ function FahrzeugBuchungen() { verticalAlign: 'top', }} > + {oos && ( + + } + label="Außer Dienst" + size="small" + color="error" + variant="outlined" + sx={{ fontSize: '0.6rem', height: 18, mb: 0.25, width: '100%' }} + /> + + )} {cellBookings.map((b) => ( 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, + }} + /> + Außer Dienst + {(Object.entries(BUCHUNGS_ART_LABELS) as [BuchungsArt, string][]).map( ([art, label]) => ( @@ -702,15 +755,29 @@ function FahrzeugBuchungen() { 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" + /> ) : ( : } - label={ - availability - ? 'Fahrzeug verfügbar' - : 'Konflikt: bereits gebucht' - } - color={availability ? 'success' : 'error'} + icon={} + label="Konflikt: bereits gebucht" + color="error" size="small" /> )} diff --git a/frontend/src/pages/FahrzeugDetail.tsx b/frontend/src/pages/FahrzeugDetail.tsx index ce22948..fd807d0 100644 --- a/frontend/src/pages/FahrzeugDetail.tsx +++ b/frontend/src/pages/FahrzeugDetail.tsx @@ -66,6 +66,7 @@ import { CreateWartungslogPayload, UpdateStatusPayload, WartungslogArt, + OverlappingBooking, } from '../types/vehicle.types'; import type { AusruestungListItem } from '../types/equipment.types'; import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types'; @@ -92,14 +93,12 @@ const STATUS_ICONS: Record = { [FahrzeugStatus.Einsatzbereit]: , [FahrzeugStatus.AusserDienstWartung]: , [FahrzeugStatus.AusserDienstSchaden]: , - [FahrzeugStatus.InLehrgang]: , }; const STATUS_CHIP_COLOR: Record = { [FahrzeugStatus.Einsatzbereit]: 'success', [FahrzeugStatus.AusserDienstWartung]: 'warning', [FahrzeugStatus.AusserDienstSchaden]: 'error', - [FahrzeugStatus.InLehrgang]: 'info', }; // ── Date helpers ────────────────────────────────────────────────────────────── @@ -118,6 +117,10 @@ function inspectionBadgeColor(tage: number | null): 'success' | 'warning' | 'err return 'success'; } +function fmtDatetime(iso: string | Date | null | undefined): string { + return fmtDate(iso ? new Date(iso).toISOString() : null); +} + // ── Übersicht Tab ───────────────────────────────────────────────────────────── interface UebersichtTabProps { @@ -130,13 +133,30 @@ const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, const [statusDialogOpen, setStatusDialogOpen] = useState(false); const [newStatus, setNewStatus] = useState(vehicle.status); const [bemerkung, setBemerkung] = useState(vehicle.status_bemerkung ?? ''); + const [ausserDienstVon, setAusserDienstVon] = useState( + vehicle.ausser_dienst_von ? new Date(vehicle.ausser_dienst_von).toISOString().slice(0, 16) : '' + ); + const [ausserDienstBis, setAusserDienstBis] = useState( + vehicle.ausser_dienst_bis ? new Date(vehicle.ausser_dienst_bis).toISOString().slice(0, 16) : '' + ); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); + const [overlappingBookings, setOverlappingBookings] = useState([]); + + const isAusserDienst = (s: FahrzeugStatus) => + s === FahrzeugStatus.AusserDienstWartung || s === FahrzeugStatus.AusserDienstSchaden; const openDialog = () => { setNewStatus(vehicle.status); setBemerkung(vehicle.status_bemerkung ?? ''); + setAusserDienstVon( + vehicle.ausser_dienst_von ? new Date(vehicle.ausser_dienst_von).toISOString().slice(0, 16) : '' + ); + setAusserDienstBis( + vehicle.ausser_dienst_bis ? new Date(vehicle.ausser_dienst_bis).toISOString().slice(0, 16) : '' + ); setSaveError(null); + setOverlappingBookings([]); setStatusDialogOpen(true); }; @@ -149,9 +169,19 @@ const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, try { setSaving(true); setSaveError(null); - const payload: UpdateStatusPayload = { status: newStatus, bemerkung }; - await vehiclesApi.updateStatus(vehicle.id, payload); + const payload: UpdateStatusPayload = { + status: newStatus, + bemerkung, + ...(isAusserDienst(newStatus) && ausserDienstVon && ausserDienstBis + ? { + ausserDienstVon: new Date(ausserDienstVon).toISOString(), + ausserDienstBis: new Date(ausserDienstBis).toISOString(), + } + : {}), + }; + const result = await vehiclesApi.updateStatus(vehicle.id, payload); setStatusDialogOpen(false); + setOverlappingBookings(result.overlappingBookings ?? []); onStatusUpdated(); } catch (err: any) { setSaveError(err?.response?.data?.message || 'Status konnte nicht gespeichert werden.'); @@ -160,6 +190,8 @@ const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, } }; + const canSave = !isAusserDienst(newStatus) || (!!ausserDienstVon && !!ausserDienstBis); + const isSchaden = vehicle.status === FahrzeugStatus.AusserDienstSchaden; // Inspection deadline badges @@ -177,6 +209,20 @@ const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, )} + {overlappingBookings.length > 0 && ( + + Folgende Buchungen überschneiden sich mit dem Außer-Dienst-Zeitraum: + + {overlappingBookings.map((b) => ( +
  • + {b.titel} · {fmtDate(b.beginn as unknown as string)} – {fmtDate(b.ende as unknown as string)} · {b.gebucht_von_name} +
  • + ))} +
    + Bitte prüfe, ob diese storniert werden müssen. +
    + )} + {/* Status panel */} @@ -184,11 +230,27 @@ const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, {STATUS_ICONS[vehicle.status]} Aktueller Status - + + + {vehicle.aktiver_lehrgang && ( + } + label="In Lehrgang" + color="info" + size="small" + /> + )} + + {(vehicle.status === FahrzeugStatus.AusserDienstWartung || vehicle.status === FahrzeugStatus.AusserDienstSchaden) && + vehicle.ausser_dienst_von && vehicle.ausser_dienst_bis && ( + + Außer Dienst: {fmtDatetime(vehicle.ausser_dienst_von)} – {fmtDatetime(vehicle.ausser_dienst_bis)} (geschätzt) + + )} {vehicle.status_bemerkung && ( {vehicle.status_bemerkung} @@ -279,6 +341,33 @@ const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, ))} + + {isAusserDienst(newStatus) && ( + <> + setAusserDienstVon(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + setAusserDienstBis(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + + Zeitangabe ist eine Schätzung + + + )} + = ({ vehicle, onStatusUpdated,