From 41fc41bee4afff1eba99a28eb1ce22693f9d92ff Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Sat, 28 Feb 2026 13:34:16 +0100 Subject: [PATCH] rework vehicle handling --- backend/src/controllers/vehicle.controller.ts | 216 +++------ .../migrations/008_simplify_inspections.sql | 53 +++ .../migrations/009_vehicle_soft_delete.sql | 57 +++ backend/src/models/vehicle.model.ts | 185 ++------ backend/src/routes/vehicle.routes.ts | 116 +---- backend/src/services/vehicle.service.ts | 419 ++++++------------ frontend/src/App.tsx | 17 + .../components/vehicles/InspectionAlerts.tsx | 66 ++- frontend/src/pages/FahrzeugDetail.tsx | 415 ++++------------- frontend/src/pages/FahrzeugForm.tsx | 400 +++++++++++++++++ frontend/src/pages/Fahrzeuge.tsx | 82 ++-- frontend/src/services/vehicles.ts | 46 +- frontend/src/types/vehicle.types.ts | 87 +--- 13 files changed, 931 insertions(+), 1228 deletions(-) create mode 100644 backend/src/database/migrations/008_simplify_inspections.sql create mode 100644 backend/src/database/migrations/009_vehicle_soft_delete.sql create mode 100644 frontend/src/pages/FahrzeugForm.tsx diff --git a/backend/src/controllers/vehicle.controller.ts b/backend/src/controllers/vehicle.controller.ts index a60eefc..b4ef654 100644 --- a/backend/src/controllers/vehicle.controller.ts +++ b/backend/src/controllers/vehicle.controller.ts @@ -1,9 +1,15 @@ import { Request, Response } from 'express'; import { z } from 'zod'; import vehicleService from '../services/vehicle.service'; -import { FahrzeugStatus, PruefungArt } from '../models/vehicle.model'; +import { FahrzeugStatus } from '../models/vehicle.model'; import logger from '../utils/logger'; +// ── UUID validation ─────────────────────────────────────────────────────────── + +function isValidUUID(s: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); +} + // ── Zod Validation Schemas ──────────────────────────────────────────────────── const FahrzeugStatusEnum = z.enum([ @@ -13,19 +19,9 @@ const FahrzeugStatusEnum = z.enum([ FahrzeugStatus.InLehrgang, ]); -const PruefungArtEnum = z.enum([ - PruefungArt.HU, - PruefungArt.AU, - PruefungArt.UVV, - PruefungArt.Leiter, - PruefungArt.Kran, - PruefungArt.Seilwinde, - PruefungArt.Sonstiges, -]); - const isoDate = z.string().regex( - /^\d{4}-\d{2}-\d{2}$/, - 'Expected ISO date format YYYY-MM-DD' + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/, + 'Erwartet ISO-Datum im Format YYYY-MM-DD' ); const CreateFahrzeugSchema = z.object({ @@ -39,30 +35,40 @@ const CreateFahrzeugSchema = z.object({ besatzung_soll: z.string().max(10).optional(), status: FahrzeugStatusEnum.optional(), status_bemerkung: z.string().max(500).optional(), - standort: z.string().max(100).optional(), - bild_url: z.string().url().max(500).optional(), + standort: z.string().min(1).max(100).optional(), + bild_url: z.string().url().max(500).refine( + (url) => /^https?:\/\//i.test(url), + 'Nur http/https URLs erlaubt' + ).optional(), paragraph57a_faellig_am: isoDate.optional(), naechste_wartung_am: isoDate.optional(), }); -const UpdateFahrzeugSchema = CreateFahrzeugSchema.partial(); +const UpdateFahrzeugSchema = z.object({ + bezeichnung: z.string().min(1).max(100).optional(), + kurzname: z.string().max(20).nullable().optional(), + amtliches_kennzeichen: z.string().max(20).nullable().optional(), + fahrgestellnummer: z.string().max(50).nullable().optional(), + baujahr: z.number().int().min(1950).max(2100).nullable().optional(), + hersteller: z.string().max(100).nullable().optional(), + typ_schluessel: z.string().max(30).nullable().optional(), + besatzung_soll: z.string().max(10).nullable().optional(), + status: FahrzeugStatusEnum.optional(), + status_bemerkung: z.string().max(500).nullable().optional(), + standort: z.string().min(1).max(100).optional(), + bild_url: z.string().url().max(500).refine( + (url) => /^https?:\/\//i.test(url), + 'Nur http/https URLs erlaubt' + ).nullable().optional(), + paragraph57a_faellig_am: isoDate.nullable().optional(), + naechste_wartung_am: isoDate.nullable().optional(), +}); const UpdateStatusSchema = z.object({ status: FahrzeugStatusEnum, bemerkung: z.string().max(500).optional().default(''), }); -const CreatePruefungSchema = z.object({ - pruefung_art: PruefungArtEnum, - faellig_am: isoDate, - durchgefuehrt_am: isoDate.optional(), - ergebnis: z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden', 'ausstehend']).optional(), - pruefende_stelle: z.string().max(150).optional(), - kosten: z.number().min(0).optional(), - dokument_url: z.string().url().max(500).optional(), - bemerkung: z.string().max(1000).optional(), -}); - const CreateWartungslogSchema = z.object({ datum: isoDate, art: z.enum(['Inspektion', 'Reparatur', 'Kraftstoff', 'Reifenwechsel', 'Hauptuntersuchung', 'Reinigung', 'Sonstiges']).optional(), @@ -76,17 +82,12 @@ const CreateWartungslogSchema = z.object({ // ── Helper ──────────────────────────────────────────────────────────────────── function getUserId(req: Request): string { - // req.user is guaranteed by the authenticate middleware return req.user!.id; } // ── Controller ──────────────────────────────────────────────────────────────── class VehicleController { - /** - * GET /api/vehicles - * Fleet overview list with per-vehicle inspection badge data. - */ async listVehicles(_req: Request, res: Response): Promise { try { const vehicles = await vehicleService.getAllVehicles(); @@ -97,10 +98,6 @@ class VehicleController { } } - /** - * GET /api/vehicles/stats - * Aggregated KPI counts for the dashboard strip. - */ async getStats(_req: Request, res: Response): Promise { try { const stats = await vehicleService.getVehicleStats(); @@ -111,23 +108,14 @@ class VehicleController { } } - /** - * GET /api/vehicles/alerts?daysAhead=30 - * Upcoming and overdue inspections — used by the InspectionAlerts dashboard panel. - * Returns alerts sorted by urgency (most overdue / soonest due first). - */ async getAlerts(req: Request, res: Response): Promise { try { - const daysAhead = Math.min( - parseInt((req.query.daysAhead as string) || '30', 10), - 365 // hard cap — never expose more than 1 year of lookahead - ); - - if (isNaN(daysAhead) || daysAhead < 0) { + const raw = parseInt((req.query.daysAhead as string) || '30', 10); + if (isNaN(raw) || raw < 0) { res.status(400).json({ success: false, message: 'Ungültiger daysAhead-Wert' }); return; } - + const daysAhead = Math.min(raw, 365); const alerts = await vehicleService.getUpcomingInspections(daysAhead); res.status(200).json({ success: true, data: alerts }); } catch (error) { @@ -136,20 +124,18 @@ class VehicleController { } } - /** - * GET /api/vehicles/:id - * Full vehicle detail with pruefstatus, inspection history, and wartungslog. - */ async getVehicle(req: Request, res: Response): Promise { try { const { id } = req.params as Record; + if (!isValidUUID(id)) { + res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' }); + return; + } const vehicle = await vehicleService.getVehicleById(id); - if (!vehicle) { res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' }); return; } - res.status(200).json({ success: true, data: vehicle }); } catch (error) { logger.error('getVehicle error', { error, id: req.params.id }); @@ -157,10 +143,6 @@ class VehicleController { } } - /** - * POST /api/vehicles - * Create a new vehicle. Requires vehicles:write permission. - */ async createVehicle(req: Request, res: Response): Promise { try { const parsed = CreateFahrzeugSchema.safeParse(req.body); @@ -172,7 +154,6 @@ class VehicleController { }); return; } - const vehicle = await vehicleService.createVehicle(parsed.data, getUserId(req)); res.status(201).json({ success: true, data: vehicle }); } catch (error) { @@ -181,13 +162,13 @@ class VehicleController { } } - /** - * PATCH /api/vehicles/:id - * Update vehicle fields. Requires vehicles:write permission. - */ async updateVehicle(req: Request, res: Response): Promise { try { const { id } = req.params as Record; + if (!isValidUUID(id)) { + res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' }); + return; + } const parsed = UpdateFahrzeugSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ @@ -197,7 +178,10 @@ class VehicleController { }); return; } - + if (Object.keys(parsed.data).length === 0) { + res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' }); + return; + } const vehicle = await vehicleService.updateVehicle(id, parsed.data, getUserId(req)); res.status(200).json({ success: true, data: vehicle }); } catch (error: any) { @@ -210,20 +194,14 @@ class VehicleController { } } - /** - * PATCH /api/vehicles/:id/status - * Live status change — the Socket.IO hook point for Tier 3. - * Requires vehicles:write permission. - * - * The `io` instance is attached to req.app in server.ts (Tier 3): - * app.set('io', io); - * and retrieved here via req.app.get('io'). - */ async updateVehicleStatus(req: Request, res: Response): Promise { try { const { id } = req.params as Record; + if (!isValidUUID(id)) { + res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' }); + return; + } const parsed = UpdateStatusSchema.safeParse(req.body); - if (!parsed.success) { res.status(400).json({ success: false, @@ -232,19 +210,10 @@ class VehicleController { }); return; } - - // Tier 3: io will be available via req.app.get('io') once Socket.IO is wired up. - // Passing undefined here is safe — the service handles it gracefully. const io = req.app.get('io') ?? undefined; - await vehicleService.updateVehicleStatus( - id, - parsed.data.status, - parsed.data.bemerkung, - getUserId(req), - io + id, parsed.data.status, parsed.data.bemerkung, getUserId(req), io ); - res.status(200).json({ success: true, message: 'Status aktualisiert' }); } catch (error: any) { if (error?.message === 'Vehicle not found') { @@ -256,60 +225,14 @@ class VehicleController { } } - // ── Inspections ───────────────────────────────────────────────────────────── - - /** - * POST /api/vehicles/:id/pruefungen - * Record an inspection (scheduled or completed). Requires vehicles:write. - */ - async addPruefung(req: Request, res: Response): Promise { - try { - const { id } = req.params as Record; - const parsed = CreatePruefungSchema.safeParse(req.body); - - if (!parsed.success) { - res.status(400).json({ - success: false, - message: 'Validierungsfehler', - errors: parsed.error.flatten().fieldErrors, - }); - return; - } - - const pruefung = await vehicleService.addPruefung(id, parsed.data, getUserId(req)); - res.status(201).json({ success: true, data: pruefung }); - } catch (error) { - logger.error('addPruefung error', { error, id: req.params.id }); - res.status(500).json({ success: false, message: 'Prüfung konnte nicht eingetragen werden' }); - } - } - - /** - * GET /api/vehicles/:id/pruefungen - * Full inspection history for a vehicle. - */ - async getPruefungen(req: Request, res: Response): Promise { - try { - const { id } = req.params as Record; - const pruefungen = await vehicleService.getPruefungenForVehicle(id); - res.status(200).json({ success: true, data: pruefungen }); - } catch (error) { - logger.error('getPruefungen error', { error, id: req.params.id }); - res.status(500).json({ success: false, message: 'Prüfungshistorie konnte nicht geladen werden' }); - } - } - - // ── Maintenance Log ────────────────────────────────────────────────────────── - - /** - * POST /api/vehicles/:id/wartung - * Add a maintenance log entry. Requires vehicles:write. - */ async addWartung(req: Request, res: Response): Promise { try { const { id } = req.params as Record; + if (!isValidUUID(id)) { + res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' }); + return; + } const parsed = CreateWartungslogSchema.safeParse(req.body); - if (!parsed.success) { res.status(400).json({ success: false, @@ -318,24 +241,27 @@ class VehicleController { }); return; } - const entry = await vehicleService.addWartungslog(id, parsed.data, getUserId(req)); res.status(201).json({ success: true, data: entry }); - } catch (error) { + } catch (error: any) { + if (error?.message === 'Vehicle not found') { + res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' }); + return; + } logger.error('addWartung error', { error, id: req.params.id }); res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht gespeichert werden' }); } } - /** - * DELETE /api/vehicles/:id - * Delete a vehicle. Requires dashboard_admin group. - */ async deleteVehicle(req: Request, res: Response): Promise { try { const { id } = req.params as Record; + if (!isValidUUID(id)) { + res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' }); + return; + } await vehicleService.deleteVehicle(id, getUserId(req)); - res.status(200).json({ success: true, message: 'Fahrzeug gelöscht' }); + res.status(204).send(); } catch (error: any) { if (error?.message === 'Vehicle not found') { res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' }); @@ -346,13 +272,13 @@ class VehicleController { } } - /** - * GET /api/vehicles/:id/wartung - * Maintenance log for a vehicle. - */ async getWartung(req: Request, res: Response): Promise { try { const { id } = req.params as Record; + if (!isValidUUID(id)) { + res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' }); + return; + } const entries = await vehicleService.getWartungslogForVehicle(id); res.status(200).json({ success: true, data: entries }); } catch (error) { diff --git a/backend/src/database/migrations/008_simplify_inspections.sql b/backend/src/database/migrations/008_simplify_inspections.sql new file mode 100644 index 0000000..1979adf --- /dev/null +++ b/backend/src/database/migrations/008_simplify_inspections.sql @@ -0,0 +1,53 @@ +-- Migration 008: Simplify inspection model +-- Remove fahrzeug_pruefungen table and related structures. +-- Only §57a (paragraph57a_faellig_am) and Wartung (naechste_wartung_am) +-- remain as the two tracked inspection deadlines, stored on fahrzeuge. + +-- Drop the pruefungen table (cascades to indexes) +DROP TABLE IF EXISTS fahrzeug_pruefungen CASCADE; + +-- Drop and recreate the fleet overview view (simplified — no CTE) +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.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; + +-- Index support for alert queries +CREATE INDEX IF NOT EXISTS idx_fahrzeuge_paragraph57a ON fahrzeuge(paragraph57a_faellig_am) WHERE paragraph57a_faellig_am IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_fahrzeuge_wartung ON fahrzeuge(naechste_wartung_am) WHERE naechste_wartung_am IS NOT NULL; diff --git a/backend/src/database/migrations/009_vehicle_soft_delete.sql b/backend/src/database/migrations/009_vehicle_soft_delete.sql new file mode 100644 index 0000000..41be5fb --- /dev/null +++ b/backend/src/database/migrations/009_vehicle_soft_delete.sql @@ -0,0 +1,57 @@ +-- Migration 009: Soft delete for vehicles +-- Adds deleted_at to fahrzeuge and refreshes the view to exclude soft-deleted rows. +-- Hard DELETE is replaced by UPDATE SET deleted_at = NOW() in the service layer. + +ALTER TABLE fahrzeuge + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +COMMENT ON COLUMN fahrzeuge.deleted_at IS + 'NULL = active vehicle. Set to timestamp when soft-deleted. Records are never physically removed.'; + +-- Partial index: only index active (non-deleted) vehicles for fast lookups +CREATE INDEX IF NOT EXISTS idx_fahrzeuge_active + ON fahrzeuge(id) + WHERE deleted_at IS NULL; + +-- Refresh the view to exclude soft-deleted vehicles +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.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/vehicle.model.ts b/backend/src/models/vehicle.model.ts index 9c64e2e..f971497 100644 --- a/backend/src/models/vehicle.model.ts +++ b/backend/src/models/vehicle.model.ts @@ -4,10 +4,6 @@ // ── Enums ───────────────────────────────────────────────────────────────────── -/** - * Operational status of a vehicle. - * These values are the CHECK constraint values in the database. - */ export enum FahrzeugStatus { Einsatzbereit = 'einsatzbereit', AusserDienstWartung = 'ausser_dienst_wartung', @@ -15,7 +11,6 @@ export enum FahrzeugStatus { InLehrgang = 'in_lehrgang', } -/** Human-readable German labels for each status value */ export const FahrzeugStatusLabel: Record = { [FahrzeugStatus.Einsatzbereit]: 'Einsatzbereit', [FahrzeugStatus.AusserDienstWartung]: 'Außer Dienst (Wartung)', @@ -23,53 +18,6 @@ export const FahrzeugStatusLabel: Record = { [FahrzeugStatus.InLehrgang]: 'In Lehrgang', }; -/** - * Types of vehicle inspections (Prüfungsarten). - * These values are the CHECK constraint values in the database. - */ -export enum PruefungArt { - HU = 'HU', // Hauptuntersuchung (TÜV) — 24-month interval - AU = 'AU', // Abgasuntersuchung — 12-month interval - UVV = 'UVV', // Unfallverhütungsvorschrift BGV D29 — 12-month - Leiter = 'Leiter', // Leiternprüfung (DLK only) — 12-month - Kran = 'Kran', // Kranprüfung — 12-month - Seilwinde = 'Seilwinde', // Seilwindenprüfung — 12-month - Sonstiges = 'Sonstiges', -} - -/** Human-readable German labels for each PruefungArt */ -export const PruefungArtLabel: Record = { - [PruefungArt.HU]: 'Hauptuntersuchung (TÜV)', - [PruefungArt.AU]: 'Abgasuntersuchung', - [PruefungArt.UVV]: 'UVV-Prüfung (BGV D29)', - [PruefungArt.Leiter]: 'Leiternprüfung (DLK)', - [PruefungArt.Kran]: 'Kranprüfung', - [PruefungArt.Seilwinde]: 'Seilwindenprüfung', - [PruefungArt.Sonstiges]: 'Sonstige Prüfung', -}; - -/** - * Standard inspection intervals in months, keyed by PruefungArt. - * Used by vehicle.service.ts to auto-calculate naechste_faelligkeit. - */ -export const PruefungIntervalMonths: Partial> = { - [PruefungArt.HU]: 24, - [PruefungArt.AU]: 12, - [PruefungArt.UVV]: 12, - [PruefungArt.Leiter]: 12, - [PruefungArt.Kran]: 12, - [PruefungArt.Seilwinde]: 12, - // Sonstiges: no standard interval — must be set manually -}; - -/** Inspection result values */ -export type PruefungErgebnis = - | 'bestanden' - | 'bestanden_mit_maengeln' - | 'nicht_bestanden' - | 'ausstehend'; - -/** Maintenance log entry types */ export type WartungslogArt = | 'Inspektion' | 'Reparatur' @@ -81,50 +29,29 @@ export type WartungslogArt = // ── Core Entities ───────────────────────────────────────────────────────────── -/** Raw database row from the `fahrzeuge` table */ export interface Fahrzeug { - id: string; // UUID - bezeichnung: string; // e.g. "LF 20/16" + id: string; + bezeichnung: string; kurzname: string | null; amtliches_kennzeichen: string | null; fahrgestellnummer: string | null; baujahr: number | null; hersteller: string | null; typ_schluessel: string | null; - besatzung_soll: string | null; // e.g. "1/8" + besatzung_soll: string | null; status: FahrzeugStatus; status_bemerkung: string | null; standort: string; bild_url: string | null; - /** §57a StVO periodic inspection due date (Austrian equivalent of HU/TÜV) */ paragraph57a_faellig_am: Date | null; - /** Next scheduled service / maintenance due date */ naechste_wartung_am: Date | null; created_at: Date; updated_at: Date; } -/** Raw database row from `fahrzeug_pruefungen` */ -export interface FahrzeugPruefung { - id: string; // UUID - fahrzeug_id: string; // UUID FK - pruefung_art: PruefungArt; - faellig_am: Date; // The hard legal deadline - durchgefuehrt_am: Date | null; - ergebnis: PruefungErgebnis | null; - naechste_faelligkeit: Date | null; - pruefende_stelle: string | null; - kosten: number | null; - dokument_url: string | null; - bemerkung: string | null; - erfasst_von: string | null; // UUID FK users - created_at: Date; -} - -/** Raw database row from `fahrzeug_wartungslog` */ export interface FahrzeugWartungslog { - id: string; // UUID - fahrzeug_id: string; // UUID FK + id: string; + fahrzeug_id: string; datum: Date; art: WartungslogArt | null; beschreibung: string; @@ -132,49 +59,12 @@ export interface FahrzeugWartungslog { kraftstoff_liter: number | null; kosten: number | null; externe_werkstatt: string | null; - erfasst_von: string | null; // UUID FK users + erfasst_von: string | null; created_at: Date; } -// ── Inspection Status per Type ──────────────────────────────────────────────── - -/** Status of a single inspection type for a vehicle */ -export interface PruefungStatus { - pruefung_id: string | null; - faellig_am: Date | null; - tage_bis_faelligkeit: number | null; // negative = overdue - ergebnis: PruefungErgebnis | null; -} - -/** - * Vehicle with its per-type inspection status. - * Comes from the `fahrzeuge_mit_pruefstatus` view. - */ -export interface FahrzeugWithPruefstatus extends Fahrzeug { - pruefstatus: { - hu: PruefungStatus; - au: PruefungStatus; - uvv: PruefungStatus; - leiter: PruefungStatus; - }; - /** Days until §57a inspection (negative = overdue) */ - paragraph57a_tage_bis_faelligkeit: number | null; - /** Days until next service (negative = overdue) */ - wartung_tage_bis_faelligkeit: number | null; - /** Minimum tage_bis_faelligkeit across all inspections (negative = any overdue) */ - naechste_pruefung_tage: number | null; - /** Full inspection history, ordered by faellig_am DESC */ - pruefungen: FahrzeugPruefung[]; - /** Maintenance log entries, ordered by datum DESC */ - wartungslog: FahrzeugWartungslog[]; -} - // ── List Item (Grid / Card view) ────────────────────────────────────────────── -/** - * Lightweight type used in the vehicle fleet overview grid. - * Includes only the fields needed to render a card plus inspection badges. - */ export interface FahrzeugListItem { id: string; bezeichnung: string; @@ -186,47 +76,44 @@ export interface FahrzeugListItem { status: FahrzeugStatus; status_bemerkung: string | null; bild_url: string | null; - /** §57a due date (primary inspection badge) */ paragraph57a_faellig_am: Date | null; paragraph57a_tage_bis_faelligkeit: number | null; - /** Next service due date */ naechste_wartung_am: Date | null; wartung_tage_bis_faelligkeit: number | null; - // Legacy pruefungen kept for backwards compat - hu_faellig_am: Date | null; - hu_tage_bis_faelligkeit: number | null; - au_faellig_am: Date | null; - au_tage_bis_faelligkeit: number | null; - uvv_faellig_am: Date | null; - uvv_tage_bis_faelligkeit: number | null; - leiter_faellig_am: Date | null; - leiter_tage_bis_faelligkeit: number | null; naechste_pruefung_tage: number | null; } +// ── Detail View ─────────────────────────────────────────────────────────────── + +export interface FahrzeugDetail extends Fahrzeug { + paragraph57a_tage_bis_faelligkeit: number | null; + wartung_tage_bis_faelligkeit: number | null; + naechste_pruefung_tage: number | null; + wartungslog: FahrzeugWartungslog[]; +} + // ── Dashboard KPI ───────────────────────────────────────────────────────────── -/** Aggregated vehicle stats for the dashboard KPI strip */ export interface VehicleStats { - total: number; - einsatzbereit: number; - ausserDienst: number; // wartung + schaden combined - inLehrgang: number; - inspectionsDue: number; // vehicles with any inspection due within 30 days - inspectionsOverdue: number; // vehicles with any inspection already overdue + total: number; + einsatzbereit: number; + ausserDienst: number; + inLehrgang: number; + inspectionsDue: number; + inspectionsOverdue: number; } // ── Inspection Alert ────────────────────────────────────────────────────────── -/** Single alert item for the dashboard InspectionAlerts component */ +export type InspectionAlertType = '57a' | 'wartung'; + export interface InspectionAlert { fahrzeugId: string; bezeichnung: string; kurzname: string | null; - pruefungId: string; - pruefungArt: PruefungArt; + type: InspectionAlertType; faelligAm: Date; - tage: number; // negative = already overdue + tage: number; } // ── Create / Update DTOs ────────────────────────────────────────────────────── @@ -244,8 +131,8 @@ export interface CreateFahrzeugData { status_bemerkung?: string; standort?: string; bild_url?: string; - paragraph57a_faellig_am?: string; // ISO date 'YYYY-MM-DD' - naechste_wartung_am?: string; // ISO date 'YYYY-MM-DD' + paragraph57a_faellig_am?: string; + naechste_wartung_am?: string; } export interface UpdateFahrzeugData { @@ -261,24 +148,12 @@ export interface UpdateFahrzeugData { status_bemerkung?: string | null; standort?: string; bild_url?: string | null; - paragraph57a_faellig_am?: string | null; // ISO date 'YYYY-MM-DD' - naechste_wartung_am?: string | null; // ISO date 'YYYY-MM-DD' -} - -export interface CreatePruefungData { - pruefung_art: PruefungArt; - faellig_am: string; // ISO date string 'YYYY-MM-DD' - durchgefuehrt_am?: string; // ISO date string, optional - ergebnis?: PruefungErgebnis; - pruefende_stelle?: string; - kosten?: number; - dokument_url?: string; - bemerkung?: string; - // naechste_faelligkeit is auto-calculated by the service — not accepted from client + paragraph57a_faellig_am?: string | null; + naechste_wartung_am?: string | null; } export interface CreateWartungslogData { - datum: string; // ISO date string 'YYYY-MM-DD' + datum: string; art?: WartungslogArt; beschreibung: string; km_stand?: number; diff --git a/backend/src/routes/vehicle.routes.ts b/backend/src/routes/vehicle.routes.ts index d576642..be356ee 100644 --- a/backend/src/routes/vehicle.routes.ts +++ b/backend/src/routes/vehicle.routes.ts @@ -3,118 +3,28 @@ import vehicleController from '../controllers/vehicle.controller'; import { authenticate } from '../middleware/auth.middleware'; import { requireGroups } from '../middleware/rbac.middleware'; -const ADMIN_GROUPS = ['dashboard_admin']; -const STATUS_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister']; +const ADMIN_GROUPS = ['dashboard_admin']; +const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister']; const router = Router(); -// ── Read-only endpoints (any authenticated user) ────────────────────────────── +// ── Read-only (any authenticated user) ─────────────────────────────────────── -/** - * GET /api/vehicles - * Fleet overview list — inspection badges included. - */ -router.get('/', authenticate, vehicleController.listVehicles.bind(vehicleController)); - -/** - * GET /api/vehicles/stats - * Dashboard KPI aggregates. - * NOTE: /stats and /alerts must be declared BEFORE /:id to avoid route conflicts. - */ -router.get('/stats', authenticate, vehicleController.getStats.bind(vehicleController)); - -/** - * GET /api/vehicles/alerts?daysAhead=30 - * Upcoming and overdue inspections for the dashboard alert panel. - */ +router.get('/', authenticate, vehicleController.listVehicles.bind(vehicleController)); +router.get('/stats', authenticate, vehicleController.getStats.bind(vehicleController)); router.get('/alerts', authenticate, vehicleController.getAlerts.bind(vehicleController)); - -/** - * GET /api/vehicles/:id - * Full vehicle detail with inspection history and maintenance log. - */ -router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController)); - -/** - * GET /api/vehicles/:id/pruefungen - * Inspection history for a single vehicle. - */ -router.get('/:id/pruefungen', authenticate, vehicleController.getPruefungen.bind(vehicleController)); - -/** - * GET /api/vehicles/:id/wartung - * Maintenance log for a single vehicle. - */ +router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController)); router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController)); -// ── Write endpoints (dashboard_admin group required) ──────────────────────── +// ── Write — admin only ──────────────────────────────────────────────────────── -/** - * POST /api/vehicles - * Create a new vehicle. - */ -router.post( - '/', - authenticate, - requireGroups(ADMIN_GROUPS), - vehicleController.createVehicle.bind(vehicleController) -); +router.post('/', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.createVehicle.bind(vehicleController)); +router.patch('/:id', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.updateVehicle.bind(vehicleController)); +router.delete('/:id', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.deleteVehicle.bind(vehicleController)); -/** - * PATCH /api/vehicles/:id - * Update vehicle fields. - */ -router.patch( - '/:id', - authenticate, - requireGroups(ADMIN_GROUPS), - vehicleController.updateVehicle.bind(vehicleController) -); +// ── Status + maintenance log — admin + fahrmeister ──────────────────────────── -/** - * PATCH /api/vehicles/:id/status - * Live status change — dashboard_admin or dashboard_fahrmeister required. - * The `io` instance is retrieved inside the controller via req.app.get('io'). - */ -router.patch( - '/:id/status', - authenticate, - requireGroups(STATUS_GROUPS), - vehicleController.updateVehicleStatus.bind(vehicleController) -); - -/** - * POST /api/vehicles/:id/pruefungen - * Record an inspection (scheduled or completed). - */ -router.post( - '/:id/pruefungen', - authenticate, - requireGroups(ADMIN_GROUPS), - vehicleController.addPruefung.bind(vehicleController) -); - -/** - * POST /api/vehicles/:id/wartung - * Add a maintenance log entry. - */ -router.post( - '/:id/wartung', - authenticate, - requireGroups(ADMIN_GROUPS), - vehicleController.addWartung.bind(vehicleController) -); - -/** - * DELETE /api/vehicles/:id - * Delete a vehicle — dashboard_admin only. - * NOTE: vehicleController.deleteVehicle needs to be implemented. - */ -router.delete( - '/:id', - authenticate, - requireGroups(ADMIN_GROUPS), - vehicleController.deleteVehicle.bind(vehicleController) -); +router.patch('/:id/status', authenticate, requireGroups(WRITE_GROUPS), vehicleController.updateVehicleStatus.bind(vehicleController)); +router.post('/:id/wartung', authenticate, requireGroups(WRITE_GROUPS), vehicleController.addWartung.bind(vehicleController)); export default router; diff --git a/backend/src/services/vehicle.service.ts b/backend/src/services/vehicle.service.ts index 0b0cf55..a02eeb7 100644 --- a/backend/src/services/vehicle.service.ts +++ b/backend/src/services/vehicle.service.ts @@ -3,79 +3,29 @@ import logger from '../utils/logger'; import { Fahrzeug, FahrzeugListItem, - FahrzeugWithPruefstatus, - FahrzeugPruefung, + FahrzeugDetail, FahrzeugWartungslog, CreateFahrzeugData, UpdateFahrzeugData, - CreatePruefungData, CreateWartungslogData, FahrzeugStatus, - PruefungArt, - PruefungIntervalMonths, VehicleStats, InspectionAlert, } from '../models/vehicle.model'; -// --------------------------------------------------------------------------- -// Helper: add N months to a Date (handles month-end edge cases) -// --------------------------------------------------------------------------- -function addMonths(date: Date, months: number): Date { - const result = new Date(date); - result.setMonth(result.getMonth() + months); - return result; -} - -// --------------------------------------------------------------------------- -// Helper: map a flat view row to PruefungStatus sub-object -// --------------------------------------------------------------------------- -function mapPruefungStatus(row: any, prefix: string) { - return { - pruefung_id: row[`${prefix}_pruefung_id`] ?? null, - faellig_am: row[`${prefix}_faellig_am`] ?? null, - tage_bis_faelligkeit: row[`${prefix}_tage_bis_faelligkeit`] != null - ? parseInt(row[`${prefix}_tage_bis_faelligkeit`], 10) - : null, - ergebnis: row[`${prefix}_ergebnis`] ?? null, - }; -} - class VehicleService { // ========================================================================= // FLEET OVERVIEW // ========================================================================= - /** - * Returns all vehicles with their next-due inspection dates per type. - * Used by the fleet overview grid (FahrzeugListItem[]). - */ async getAllVehicles(): Promise { try { const result = await pool.query(` SELECT - id, - bezeichnung, - kurzname, - amtliches_kennzeichen, - baujahr, - hersteller, - besatzung_soll, - status, - status_bemerkung, - bild_url, - paragraph57a_faellig_am, - paragraph57a_tage_bis_faelligkeit, - naechste_wartung_am, - wartung_tage_bis_faelligkeit, - hu_faellig_am, - hu_tage_bis_faelligkeit, - au_faellig_am, - au_tage_bis_faelligkeit, - uvv_faellig_am, - uvv_tage_bis_faelligkeit, - leiter_faellig_am, - leiter_tage_bis_faelligkeit, - naechste_pruefung_tage + id, bezeichnung, kurzname, amtliches_kennzeichen, + baujahr, hersteller, besatzung_soll, status, status_bemerkung, + 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 `); @@ -84,17 +34,9 @@ class VehicleService { ...row, paragraph57a_tage_bis_faelligkeit: row.paragraph57a_tage_bis_faelligkeit != null ? parseInt(row.paragraph57a_tage_bis_faelligkeit, 10) : null, - wartung_tage_bis_faelligkeit: row.wartung_tage_bis_faelligkeit != null + wartung_tage_bis_faelligkeit: row.wartung_tage_bis_faelligkeit != null ? parseInt(row.wartung_tage_bis_faelligkeit, 10) : null, - hu_tage_bis_faelligkeit: row.hu_tage_bis_faelligkeit != null - ? parseInt(row.hu_tage_bis_faelligkeit, 10) : null, - au_tage_bis_faelligkeit: row.au_tage_bis_faelligkeit != null - ? parseInt(row.au_tage_bis_faelligkeit, 10) : null, - uvv_tage_bis_faelligkeit: row.uvv_tage_bis_faelligkeit != null - ? parseInt(row.uvv_tage_bis_faelligkeit, 10) : null, - leiter_tage_bis_faelligkeit: row.leiter_tage_bis_faelligkeit != null - ? parseInt(row.leiter_tage_bis_faelligkeit, 10) : null, - naechste_pruefung_tage: row.naechste_pruefung_tage != null + naechste_pruefung_tage: row.naechste_pruefung_tage != null ? parseInt(row.naechste_pruefung_tage, 10) : null, })) as FahrzeugListItem[]; } catch (error) { @@ -107,13 +49,8 @@ class VehicleService { // VEHICLE DETAIL // ========================================================================= - /** - * Returns a single vehicle with full pruefstatus, inspection history, - * and maintenance log. - */ - async getVehicleById(id: string): Promise { + async getVehicleById(id: string): Promise { try { - // 1) Main record + inspection status from view const vehicleResult = await pool.query( `SELECT * FROM fahrzeuge_mit_pruefstatus WHERE id = $1`, [id] @@ -123,15 +60,6 @@ class VehicleService { const row = vehicleResult.rows[0]; - // 2) Full inspection history - const pruefungenResult = await pool.query( - `SELECT * FROM fahrzeug_pruefungen - WHERE fahrzeug_id = $1 - ORDER BY faellig_am DESC, created_at DESC`, - [id] - ); - - // 3) Maintenance log const wartungslogResult = await pool.query( `SELECT * FROM fahrzeug_wartungslog WHERE fahrzeug_id = $1 @@ -139,7 +67,7 @@ class VehicleService { [id] ); - const vehicle: FahrzeugWithPruefstatus = { + const vehicle: FahrzeugDetail = { id: row.id, bezeichnung: row.bezeichnung, kurzname: row.kurzname, @@ -157,20 +85,16 @@ class VehicleService { naechste_wartung_am: row.naechste_wartung_am ?? null, created_at: row.created_at, updated_at: row.updated_at, - pruefstatus: { - hu: mapPruefungStatus(row, 'hu'), - au: mapPruefungStatus(row, 'au'), - uvv: mapPruefungStatus(row, 'uvv'), - leiter: mapPruefungStatus(row, 'leiter'), - }, paragraph57a_tage_bis_faelligkeit: row.paragraph57a_tage_bis_faelligkeit != null ? parseInt(row.paragraph57a_tage_bis_faelligkeit, 10) : null, wartung_tage_bis_faelligkeit: row.wartung_tage_bis_faelligkeit != null ? parseInt(row.wartung_tage_bis_faelligkeit, 10) : null, naechste_pruefung_tage: row.naechste_pruefung_tage != null ? parseInt(row.naechste_pruefung_tage, 10) : null, - pruefungen: pruefungenResult.rows as FahrzeugPruefung[], - wartungslog: wartungslogResult.rows as FahrzeugWartungslog[], + wartungslog: wartungslogResult.rows.map(r => ({ + ...r, + kosten: r.kosten != null ? Number(r.kosten) : null, + })) as FahrzeugWartungslog[], }; return vehicle; @@ -184,10 +108,7 @@ class VehicleService { // CRUD // ========================================================================= - async createVehicle( - data: CreateFahrzeugData, - createdBy: string - ): Promise { + async createVehicle(data: CreateFahrzeugData, createdBy: string): Promise { try { const result = await pool.query( `INSERT INTO fahrzeuge ( @@ -224,11 +145,7 @@ class VehicleService { } } - async updateVehicle( - id: string, - data: UpdateFahrzeugData, - updatedBy: string - ): Promise { + async updateVehicle(id: string, data: UpdateFahrzeugData, updatedBy: string): Promise { try { const fields: string[] = []; const values: unknown[] = []; @@ -258,9 +175,9 @@ class VehicleService { throw new Error('No fields to update'); } - values.push(id); // for WHERE clause + values.push(id); const result = await pool.query( - `UPDATE fahrzeuge SET ${fields.join(', ')} WHERE id = $${p} RETURNING *`, + `UPDATE fahrzeuge SET ${fields.join(', ')} WHERE id = $${p} AND deleted_at IS NULL RETURNING *`, values ); @@ -280,7 +197,10 @@ class VehicleService { async deleteVehicle(id: string, deletedBy: string): Promise { try { const result = await pool.query( - `DELETE FROM fahrzeuge WHERE id = $1 RETURNING id`, + `UPDATE fahrzeuge + SET deleted_at = NOW() + WHERE id = $1 AND deleted_at IS NULL + RETURNING id`, [id] ); @@ -288,7 +208,7 @@ class VehicleService { throw new Error('Vehicle not found'); } - logger.info('Vehicle deleted', { id, by: deletedBy }); + logger.info('Vehicle soft-deleted', { id, by: deletedBy }); } catch (error) { logger.error('VehicleService.deleteVehicle failed', { error, id }); throw error; @@ -297,22 +217,8 @@ class VehicleService { // ========================================================================= // STATUS MANAGEMENT - // Socket.io-ready: accepts optional `io` parameter. - // In Tier 3, pass the real Socket.IO server instance here. - // The endpoint contract is: PATCH /api/vehicles/:id/status // ========================================================================= - /** - * Updates vehicle status and optionally broadcasts a Socket.IO event. - * - * Socket.IO integration (Tier 3): - * Pass the live `io` instance from server.ts. When provided, emits: - * event: 'vehicle:statusChanged' - * payload: { vehicleId, bezeichnung, oldStatus, newStatus, bemerkung, updatedBy, timestamp } - * All connected clients on the default namespace receive the update immediately. - * - * @param io - Optional Socket.IO server instance (injected from app layer in Tier 3) - */ async updateVehicleStatus( id: string, status: FahrzeugStatus, @@ -320,38 +226,33 @@ class VehicleService { updatedBy: string, io?: any ): Promise { + const client = await pool.connect(); try { - // Fetch old status for Socket.IO payload and logging - const oldResult = await pool.query( - `SELECT bezeichnung, status FROM fahrzeuge WHERE id = $1`, + await client.query('BEGIN'); + + const oldResult = await client.query( + `SELECT bezeichnung, status FROM fahrzeuge WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`, [id] ); if (oldResult.rows.length === 0) { + await client.query('ROLLBACK'); throw new Error('Vehicle not found'); } const { bezeichnung, status: oldStatus } = oldResult.rows[0]; - await pool.query( - `UPDATE fahrzeuge - SET status = $1, status_bemerkung = $2 - WHERE id = $3`, + await client.query( + `UPDATE fahrzeuge SET status = $1, status_bemerkung = $2 WHERE id = $3`, [status, bemerkung || null, id] ); - logger.info('Vehicle status updated', { - id, - from: oldStatus, - to: status, - by: updatedBy, - }); + await client.query('COMMIT'); + + logger.info('Vehicle status updated', { id, from: oldStatus, to: status, by: updatedBy }); - // ── Socket.IO broadcast (Tier 3 integration point) ────────────────── - // When `io` is provided (Tier 3), broadcast the status change to all - // connected dashboard clients so the live status board updates in real time. if (io) { - const payload = { + io.emit('vehicle:statusChanged', { vehicleId: id, bezeichnung, oldStatus, @@ -359,143 +260,14 @@ class VehicleService { bemerkung: bemerkung || null, updatedBy, timestamp: new Date().toISOString(), - }; - io.emit('vehicle:statusChanged', payload); - logger.debug('Emitted vehicle:statusChanged via Socket.IO', { vehicleId: id }); + }); } } catch (error) { + await client.query('ROLLBACK').catch(() => {}); logger.error('VehicleService.updateVehicleStatus failed', { error, id }); throw error; - } - } - - // ========================================================================= - // INSPECTIONS - // ========================================================================= - - /** - * Records a new inspection entry. - * Automatically calculates naechste_faelligkeit based on standard intervals - * when durchgefuehrt_am is provided and the art has a known interval. - */ - async addPruefung( - fahrzeugId: string, - data: CreatePruefungData, - createdBy: string - ): Promise { - try { - // Auto-calculate naechste_faelligkeit - let naechsteFaelligkeit: string | null = null; - - if (data.durchgefuehrt_am) { - const intervalMonths = PruefungIntervalMonths[data.pruefung_art]; - if (intervalMonths !== undefined) { - const durchgefuehrt = new Date(data.durchgefuehrt_am); - naechsteFaelligkeit = addMonths(durchgefuehrt, intervalMonths) - .toISOString() - .split('T')[0]; - } - } - - const result = await pool.query( - `INSERT INTO fahrzeug_pruefungen ( - fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am, - ergebnis, naechste_faelligkeit, pruefende_stelle, - kosten, dokument_url, bemerkung, erfasst_von - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) - RETURNING *`, - [ - fahrzeugId, - data.pruefung_art, - data.faellig_am, - data.durchgefuehrt_am ?? null, - data.ergebnis ?? 'ausstehend', - naechsteFaelligkeit, - data.pruefende_stelle ?? null, - data.kosten ?? null, - data.dokument_url ?? null, - data.bemerkung ?? null, - createdBy, - ] - ); - - const pruefung = result.rows[0] as FahrzeugPruefung; - logger.info('Pruefung added', { - pruefungId: pruefung.id, - fahrzeugId, - art: data.pruefung_art, - by: createdBy, - }); - return pruefung; - } catch (error) { - logger.error('VehicleService.addPruefung failed', { error, fahrzeugId }); - throw new Error('Failed to add inspection record'); - } - } - - /** - * Returns the full inspection history for a specific vehicle, - * ordered newest-first. - */ - async getPruefungenForVehicle(fahrzeugId: string): Promise { - try { - const result = await pool.query( - `SELECT * FROM fahrzeug_pruefungen - WHERE fahrzeug_id = $1 - ORDER BY faellig_am DESC, created_at DESC`, - [fahrzeugId] - ); - return result.rows.map(r => ({ - ...r, - kosten: r.kosten != null ? Number(r.kosten) : null, - })) as FahrzeugPruefung[]; - } catch (error) { - logger.error('VehicleService.getPruefungenForVehicle failed', { error, fahrzeugId }); - throw new Error('Failed to fetch inspection history'); - } - } - - /** - * Returns all upcoming or overdue inspections within the given lookahead window. - * Used by the dashboard InspectionAlerts panel. - * - * @param daysAhead - How many days into the future to look (e.g. 30). - * Pass a very large number (e.g. 9999) to include all overdue too. - */ - async getUpcomingInspections(daysAhead: number): Promise { - try { - // We include already-overdue inspections (tage < 0) AND upcoming within window. - // Only open (not yet completed) inspections are relevant. - const result = await pool.query( - `SELECT - p.id AS pruefung_id, - p.fahrzeug_id, - p.pruefung_art, - p.faellig_am, - (p.faellig_am::date - CURRENT_DATE) AS tage, - f.bezeichnung, - f.kurzname - FROM fahrzeug_pruefungen p - JOIN fahrzeuge f ON f.id = p.fahrzeug_id - WHERE - p.durchgefuehrt_am IS NULL - AND (p.faellig_am::date - CURRENT_DATE) <= $1 - ORDER BY p.faellig_am ASC`, - [daysAhead] - ); - - return result.rows.map((row) => ({ - fahrzeugId: row.fahrzeug_id, - bezeichnung: row.bezeichnung, - kurzname: row.kurzname, - pruefungId: row.pruefung_id, - pruefungArt: row.pruefung_art as PruefungArt, - faelligAm: row.faellig_am, - tage: parseInt(row.tage, 10), - })) as InspectionAlert[]; - } catch (error) { - logger.error('VehicleService.getUpcomingInspections failed', { error, daysAhead }); - throw new Error('Failed to fetch inspection alerts'); + } finally { + client.release(); } } @@ -509,6 +281,14 @@ class VehicleService { createdBy: string ): Promise { try { + const check = await pool.query( + `SELECT 1 FROM fahrzeuge WHERE id = $1 AND deleted_at IS NULL`, + [fahrzeugId] + ); + if (check.rows.length === 0) { + throw new Error('Vehicle not found'); + } + const result = await pool.query( `INSERT INTO fahrzeug_wartungslog ( fahrzeug_id, datum, art, beschreibung, @@ -529,15 +309,11 @@ class VehicleService { ); const entry = result.rows[0] as FahrzeugWartungslog; - logger.info('Wartungslog entry added', { - entryId: entry.id, - fahrzeugId, - by: createdBy, - }); + logger.info('Wartungslog entry added', { entryId: entry.id, fahrzeugId, by: createdBy }); return entry; } catch (error) { logger.error('VehicleService.addWartungslog failed', { error, fahrzeugId }); - throw new Error('Failed to add maintenance log entry'); + throw error; } } @@ -563,14 +339,9 @@ class VehicleService { // DASHBOARD KPI // ========================================================================= - /** - * Returns aggregate counts for the dashboard stats strip. - * inspectionsDue = vehicles with at least one inspection due within 30 days - * inspectionsOverdue = vehicles with at least one inspection already overdue - */ async getVehicleStats(): Promise { try { - const result = await pool.query(` + const totalsResult = await pool.query(` SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE status = 'einsatzbereit') AS einsatzbereit, @@ -579,22 +350,31 @@ class VehicleService { ) AS ausser_dienst, COUNT(*) FILTER (WHERE status = 'in_lehrgang') AS in_lehrgang FROM fahrzeuge + WHERE deleted_at IS NULL `); const alertResult = await pool.query(` SELECT - COUNT(DISTINCT fahrzeug_id) FILTER ( - WHERE (faellig_am::date - CURRENT_DATE) BETWEEN 0 AND 30 + COUNT(*) FILTER ( + WHERE ( + (paragraph57a_faellig_am IS NOT NULL AND paragraph57a_faellig_am::date - CURRENT_DATE BETWEEN 0 AND 30) + OR + (naechste_wartung_am IS NOT NULL AND naechste_wartung_am::date - CURRENT_DATE BETWEEN 0 AND 30) + ) ) AS inspections_due, - COUNT(DISTINCT fahrzeug_id) FILTER ( - WHERE faellig_am::date < CURRENT_DATE + COUNT(*) FILTER ( + WHERE ( + (paragraph57a_faellig_am IS NOT NULL AND paragraph57a_faellig_am::date < CURRENT_DATE) + OR + (naechste_wartung_am IS NOT NULL AND naechste_wartung_am::date < CURRENT_DATE) + ) ) AS inspections_overdue - FROM fahrzeug_pruefungen - WHERE durchgefuehrt_am IS NULL + FROM fahrzeuge + WHERE deleted_at IS NULL `); - const totals = result.rows[0]; - const alerts = alertResult.rows[0]; + const totals = totalsResult.rows[0]; + const alerts = alertResult.rows[0]; return { total: parseInt(totals.total, 10), @@ -609,6 +389,73 @@ class VehicleService { throw new Error('Failed to fetch vehicle stats'); } } + + async getUpcomingInspections(daysAhead: number): Promise { + try { + const result = await pool.query( + `SELECT + id AS fahrzeug_id, + bezeichnung, + kurzname, + paragraph57a_faellig_am, + paragraph57a_faellig_am::date - CURRENT_DATE AS paragraph57a_tage, + naechste_wartung_am, + naechste_wartung_am::date - CURRENT_DATE AS wartung_tage + FROM fahrzeuge + WHERE + deleted_at IS NULL + AND ( + (paragraph57a_faellig_am IS NOT NULL AND paragraph57a_faellig_am::date - CURRENT_DATE <= $1) + OR + (naechste_wartung_am IS NOT NULL AND naechste_wartung_am::date - CURRENT_DATE <= $1) + ) + ORDER BY LEAST( + CASE WHEN paragraph57a_faellig_am IS NOT NULL + THEN paragraph57a_faellig_am::date - CURRENT_DATE END, + CASE WHEN naechste_wartung_am IS NOT NULL + THEN naechste_wartung_am::date - CURRENT_DATE END + ) ASC NULLS LAST`, + [daysAhead] + ); + + const alerts: InspectionAlert[] = []; + + for (const row of result.rows) { + if (row.paragraph57a_faellig_am !== null && row.paragraph57a_tage !== null) { + const tage = parseInt(row.paragraph57a_tage, 10); + if (tage <= daysAhead) { + alerts.push({ + fahrzeugId: row.fahrzeug_id, + bezeichnung: row.bezeichnung, + kurzname: row.kurzname, + type: '57a', + faelligAm: row.paragraph57a_faellig_am, + tage, + }); + } + } + if (row.naechste_wartung_am !== null && row.wartung_tage !== null) { + const tage = parseInt(row.wartung_tage, 10); + if (tage <= daysAhead) { + alerts.push({ + fahrzeugId: row.fahrzeug_id, + bezeichnung: row.bezeichnung, + kurzname: row.kurzname, + type: 'wartung', + faelligAm: row.naechste_wartung_am, + tage, + }); + } + } + } + + alerts.sort((a, b) => a.tage - b.tage); + return alerts; + } catch (error) { + logger.error('VehicleService.getUpcomingInspections failed', { error, daysAhead }); + throw new Error('Failed to fetch inspection alerts'); + } + } } export default new VehicleService(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 405e4d9..2059474 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import Einsaetze from './pages/Einsaetze'; import EinsatzDetail from './pages/EinsatzDetail'; import Fahrzeuge from './pages/Fahrzeuge'; import FahrzeugDetail from './pages/FahrzeugDetail'; +import FahrzeugForm from './pages/FahrzeugForm'; import Ausruestung from './pages/Ausruestung'; import Mitglieder from './pages/Mitglieder'; import MitgliedDetail from './pages/MitgliedDetail'; @@ -76,6 +77,22 @@ function App() { } /> + + + + } + /> + + + + } + /> = ({ useEffect(() => { let mounted = true; - const fetchAlerts = async () => { try { setLoading(true); @@ -68,7 +64,6 @@ const InspectionAlerts: React.FC = ({ if (mounted) setLoading(false); } }; - fetchAlerts(); return () => { mounted = false; }; }, [daysAhead]); @@ -92,12 +87,11 @@ const InspectionAlerts: React.FC = ({ if (hideWhenEmpty) return null; return ( - Alle Prüfungsfristen sind aktuell. Keine Fälligkeiten in den nächsten {daysAhead} Tagen. + Alle Fristen sind aktuell. Keine Fälligkeiten in den nächsten {daysAhead} Tagen. ); } - // Group by urgency const overdue = alerts.filter((a) => a.tage < 0); const urgent = alerts.filter((a) => a.tage >= 0 && a.tage <= 14); const warning = alerts.filter((a) => a.tage > 14); @@ -117,35 +111,37 @@ const InspectionAlerts: React.FC = ({ {label} {items.map((alert) => { - const artLabel = PruefungArtLabel[alert.pruefungArt as PruefungArt] ?? alert.pruefungArt; - const dateStr = formatDate(alert.faelligAm); - const tageText = alert.tage < 0 + const typeLabel = alertTypeLabel(alert.type); + const dateStr = formatDate(alert.faelligAm); + const tageText = alert.tage < 0 ? `seit ${Math.abs(alert.tage)} Tag${Math.abs(alert.tage) === 1 ? '' : 'en'} überfällig` : alert.tage === 0 ? 'heute fällig' : `fällig in ${alert.tage} Tag${alert.tage === 1 ? '' : 'en'}`; return ( - - - - {alert.bezeichnung} - {alert.kurzname ? ` (${alert.kurzname})` : ''} - - {' — '} - {artLabel} - {' '} - - {tageText} ({dateStr}) - - - + + + {alert.bezeichnung} + {alert.kurzname ? ` (${alert.kurzname})` : ''} + + {' — '} + {typeLabel} + {' '} + + {tageText} ({dateStr}) + + ); })} diff --git a/frontend/src/pages/FahrzeugDetail.tsx b/frontend/src/pages/FahrzeugDetail.tsx index cd6c860..1563319 100644 --- a/frontend/src/pages/FahrzeugDetail.tsx +++ b/frontend/src/pages/FahrzeugDetail.tsx @@ -22,13 +22,6 @@ import { Tab, Tabs, TextField, - Timeline, - TimelineConnector, - TimelineContent, - TimelineDot, - TimelineItem, - TimelineOppositeContent, - TimelineSeparator, Tooltip, Typography, } from '@mui/material'; @@ -39,6 +32,7 @@ import { Build, CheckCircle, DirectionsCar, + Edit, Error as ErrorIcon, LocalFireDepartment, PauseCircle, @@ -51,16 +45,12 @@ import DashboardLayout from '../components/dashboard/DashboardLayout'; import { vehiclesApi } from '../services/vehicles'; import { FahrzeugDetail, - FahrzeugPruefung, FahrzeugWartungslog, FahrzeugStatus, FahrzeugStatusLabel, - PruefungArt, - PruefungArtLabel, - CreatePruefungPayload, CreateWartungslogPayload, + UpdateStatusPayload, WartungslogArt, - PruefungErgebnis, } from '../types/vehicle.types'; import { usePermissions } from '../hooks/usePermissions'; @@ -125,11 +115,24 @@ const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); + const openDialog = () => { + setNewStatus(vehicle.status); + setBemerkung(vehicle.status_bemerkung ?? ''); + setSaveError(null); + setStatusDialogOpen(true); + }; + + const closeDialog = () => { + setSaveError(null); + setStatusDialogOpen(false); + }; + const handleSaveStatus = async () => { try { setSaving(true); setSaveError(null); - await vehiclesApi.updateStatus(vehicle.id, { status: newStatus, bemerkung }); + const payload: UpdateStatusPayload = { status: newStatus, bemerkung }; + await vehiclesApi.updateStatus(vehicle.id, payload); setStatusDialogOpen(false); onStatusUpdated(); } catch { @@ -141,6 +144,12 @@ const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, const isSchaden = vehicle.status === FahrzeugStatus.AusserDienstSchaden; + // Inspection deadline badges + const inspItems: { label: string; faelligAm: string | null; tage: number | null }[] = [ + { label: '§57a Periodische Prüfung', faelligAm: vehicle.paragraph57a_faellig_am, tage: vehicle.paragraph57a_tage_bis_faelligkeit }, + { label: 'Nächste Wartung / Service', faelligAm: vehicle.naechste_wartung_am, tage: vehicle.wartung_tage_bis_faelligkeit }, + ]; + return ( {isSchaden && ( @@ -156,9 +165,7 @@ const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, {STATUS_ICONS[vehicle.status]} - - Aktueller Status - + Aktueller Status = ({ vehicle, onStatusUpdated, )} - + {canChangeStatus && ( + + )} @@ -198,8 +198,6 @@ const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, { label: 'Typ (DIN 14502)', value: vehicle.typ_schluessel }, { label: 'Besatzung (Soll)', value: vehicle.besatzung_soll }, { label: 'Standort', value: vehicle.standort }, - { label: '§57a fällig am', value: fmtDate(vehicle.paragraph57a_faellig_am) !== '—' ? fmtDate(vehicle.paragraph57a_faellig_am) : null }, - { label: 'Nächste Wartung', value: fmtDate(vehicle.naechste_wartung_am) !== '—' ? fmtDate(vehicle.naechste_wartung_am) : null }, ].map(({ label, value }) => ( @@ -210,48 +208,40 @@ const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, ))} - {/* Inspection status quick view */} + {/* Inspection deadline quick view */} - Prüffristen Übersicht + Prüf- und Wartungsfristen - {Object.entries(vehicle.pruefstatus).map(([key, ps]) => { - const art = key.toUpperCase() as PruefungArt; - const label = PruefungArtLabel[art] ?? key; - const color = inspectionBadgeColor(ps.tage_bis_faelligkeit); + {inspItems.map(({ label, faelligAm, tage }) => { + const color = inspectionBadgeColor(tage); return ( - + {label} - {ps.faellig_am ? ( + {faelligAm ? ( <> - : undefined + tage !== null && tage < 0 + ? `ÜBERFÄLLIG (${fmtDate(faelligAm)})` + : `Fällig: ${fmtDate(faelligAm)}` } + icon={tage !== null && tage < 0 ? : undefined} sx={{ mt: 0.5 }} /> - {ps.tage_bis_faelligkeit !== null && ps.tage_bis_faelligkeit >= 0 && ( + {tage !== null && tage >= 0 && ( - in {ps.tage_bis_faelligkeit} Tagen + in {tage} Tagen )} ) : ( - - Keine Daten - + Kein Datum erfasst )} @@ -260,12 +250,7 @@ const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, {/* Status change dialog */} - setStatusDialogOpen(false)} - maxWidth="sm" - fullWidth - > + Fahrzeugstatus ändern {saveError && {saveError}} @@ -278,9 +263,7 @@ const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, onChange={(e) => setNewStatus(e.target.value as FahrzeugStatus)} > {Object.values(FahrzeugStatus).map((s) => ( - - {FahrzeugStatusLabel[s]} - + {FahrzeugStatusLabel[s]} ))} @@ -295,7 +278,7 @@ const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, /> - + - - - - - ); -}; - // ── Wartung Tab ─────────────────────────────────────────────────────────────── interface WartungTabProps { @@ -561,11 +303,11 @@ interface WartungTabProps { } const WARTUNG_ART_ICONS: Record = { - Kraftstoff: , - Reparatur: , - Inspektion: , - Hauptuntersuchung:, - default: , + Kraftstoff: , + Reparatur: , + Inspektion: , + Hauptuntersuchung: , + default: , }; const WartungTab: React.FC = ({ fahrzeugId, wartungslog, onAdded, canWrite }) => { @@ -612,8 +354,6 @@ const WartungTab: React.FC = ({ fahrzeugId, wartungslog, onAdde {wartungslog.length === 0 ? ( Noch keine Wartungseinträge erfasst. ) : ( - // MUI Timeline is available via @mui/lab — using Paper list as fallback - // since @mui/lab is not in current package.json } spacing={0}> {wartungslog.map((entry) => { const artIcon = WARTUNG_ART_ICONS[entry.art ?? ''] ?? WARTUNG_ART_ICONS.default; @@ -623,9 +363,7 @@ const WartungTab: React.FC = ({ fahrzeugId, wartungslog, onAdde {fmtDate(entry.datum)} - {entry.art && ( - - )} + {entry.art && } {entry.beschreibung} @@ -795,11 +533,7 @@ function FahrzeugDetail() { {error ?? 'Fahrzeug nicht gefunden.'} - @@ -807,10 +541,13 @@ function FahrzeugDetail() { ); } + const hasOverdue = + (vehicle.paragraph57a_tage_bis_faelligkeit !== null && vehicle.paragraph57a_tage_bis_faelligkeit < 0) || + (vehicle.wartung_tage_bis_faelligkeit !== null && vehicle.wartung_tage_bis_faelligkeit < 0); + return ( - {/* Breadcrumb / back */} - {/* Page title */} @@ -839,16 +575,26 @@ function FahrzeugDetail() { )} - + + {isAdmin && ( + + navigate(`/fahrzeuge/${vehicle.id}/bearbeiten`)} + aria-label="Fahrzeug bearbeiten" + > + + + + )} - {/* Tabs */} - Prüfungen + Wartung - : 'Prüfungen' + : 'Wartung' } /> - - {/* Tab content */} - - - - - - + - + diff --git a/frontend/src/pages/FahrzeugForm.tsx b/frontend/src/pages/FahrzeugForm.tsx new file mode 100644 index 0000000..877a382 --- /dev/null +++ b/frontend/src/pages/FahrzeugForm.tsx @@ -0,0 +1,400 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { + Alert, + Box, + Button, + CircularProgress, + Container, + FormControl, + Grid, + InputLabel, + MenuItem, + Paper, + Select, + TextField, + Typography, +} from '@mui/material'; +import { ArrowBack, Save } from '@mui/icons-material'; +import { useNavigate, useParams } from 'react-router-dom'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { vehiclesApi } from '../services/vehicles'; +import { + FahrzeugStatus, + FahrzeugStatusLabel, + CreateFahrzeugPayload, + UpdateFahrzeugPayload, +} from '../types/vehicle.types'; + +// ── Form state shape ────────────────────────────────────────────────────────── + +interface FormState { + bezeichnung: string; + kurzname: string; + amtliches_kennzeichen: string; + fahrgestellnummer: string; + baujahr: string; // kept as string for input, parsed on submit + hersteller: string; + typ_schluessel: string; + besatzung_soll: string; + status: FahrzeugStatus; + status_bemerkung: string; + standort: string; + bild_url: string; + paragraph57a_faellig_am: string; // ISO date 'YYYY-MM-DD' or '' + naechste_wartung_am: string; // ISO date 'YYYY-MM-DD' or '' +} + +const EMPTY_FORM: FormState = { + bezeichnung: '', + kurzname: '', + amtliches_kennzeichen: '', + fahrgestellnummer: '', + baujahr: '', + hersteller: '', + typ_schluessel: '', + besatzung_soll: '', + status: FahrzeugStatus.Einsatzbereit, + status_bemerkung: '', + standort: 'Feuerwehrhaus', + bild_url: '', + paragraph57a_faellig_am: '', + naechste_wartung_am: '', +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Convert a Date ISO string like '2026-03-15T00:00:00.000Z' to 'YYYY-MM-DD' */ +function toDateInput(iso: string | null | undefined): string { + if (!iso) return ''; + return iso.slice(0, 10); +} + +// ── Component ───────────────────────────────────────────────────────────────── + +function FahrzeugForm() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const isEditMode = Boolean(id); + + const [form, setForm] = useState(EMPTY_FORM); + const [loading, setLoading] = useState(isEditMode); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [saveError, setSaveError] = useState(null); + const [fieldErrors, setFieldErrors] = useState>>({}); + + const fetchVehicle = useCallback(async () => { + if (!id) return; + try { + setLoading(true); + setError(null); + const vehicle = await vehiclesApi.getById(id); + setForm({ + bezeichnung: vehicle.bezeichnung, + kurzname: vehicle.kurzname ?? '', + amtliches_kennzeichen: vehicle.amtliches_kennzeichen ?? '', + fahrgestellnummer: vehicle.fahrgestellnummer ?? '', + baujahr: vehicle.baujahr?.toString() ?? '', + hersteller: vehicle.hersteller ?? '', + typ_schluessel: vehicle.typ_schluessel ?? '', + besatzung_soll: vehicle.besatzung_soll ?? '', + status: vehicle.status, + status_bemerkung: vehicle.status_bemerkung ?? '', + standort: vehicle.standort, + bild_url: vehicle.bild_url ?? '', + paragraph57a_faellig_am: toDateInput(vehicle.paragraph57a_faellig_am), + naechste_wartung_am: toDateInput(vehicle.naechste_wartung_am), + }); + } catch { + setError('Fahrzeug konnte nicht geladen werden.'); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { + if (isEditMode) fetchVehicle(); + }, [isEditMode, fetchVehicle]); + + const validate = (): boolean => { + const errors: Partial> = {}; + if (!form.bezeichnung.trim()) { + errors.bezeichnung = 'Bezeichnung ist erforderlich.'; + } + if (form.baujahr && (isNaN(Number(form.baujahr)) || Number(form.baujahr) < 1950 || Number(form.baujahr) > 2100)) { + errors.baujahr = 'Baujahr muss zwischen 1950 und 2100 liegen.'; + } + setFieldErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleSubmit = async () => { + if (!validate()) return; + + try { + setSaving(true); + setSaveError(null); + + if (isEditMode && id) { + const payload: UpdateFahrzeugPayload = { + bezeichnung: form.bezeichnung.trim() || undefined, + kurzname: form.kurzname.trim() || undefined, + amtliches_kennzeichen: form.amtliches_kennzeichen.trim() || undefined, + fahrgestellnummer: form.fahrgestellnummer.trim() || undefined, + baujahr: form.baujahr ? Number(form.baujahr) : undefined, + hersteller: form.hersteller.trim() || undefined, + typ_schluessel: form.typ_schluessel.trim() || undefined, + besatzung_soll: form.besatzung_soll.trim() || undefined, + status: form.status, + status_bemerkung: form.status_bemerkung.trim() || undefined, + standort: form.standort.trim() || 'Feuerwehrhaus', + bild_url: form.bild_url.trim() || undefined, + paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined, + naechste_wartung_am: form.naechste_wartung_am || undefined, + }; + await vehiclesApi.update(id, payload); + navigate(`/fahrzeuge/${id}`); + } else { + const payload: CreateFahrzeugPayload = { + bezeichnung: form.bezeichnung.trim(), + kurzname: form.kurzname.trim() || undefined, + amtliches_kennzeichen: form.amtliches_kennzeichen.trim() || undefined, + fahrgestellnummer: form.fahrgestellnummer.trim() || undefined, + baujahr: form.baujahr ? Number(form.baujahr) : undefined, + hersteller: form.hersteller.trim() || undefined, + typ_schluessel: form.typ_schluessel.trim() || undefined, + besatzung_soll: form.besatzung_soll.trim() || undefined, + status: form.status, + status_bemerkung: form.status_bemerkung.trim() || undefined, + standort: form.standort.trim() || 'Feuerwehrhaus', + bild_url: form.bild_url.trim() || undefined, + paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined, + naechste_wartung_am: form.naechste_wartung_am || undefined, + }; + const newVehicle = await vehiclesApi.create(payload); + navigate(`/fahrzeuge/${newVehicle.id}`); + } + } catch { + setSaveError( + isEditMode + ? 'Fahrzeug konnte nicht gespeichert werden.' + : 'Fahrzeug konnte nicht erstellt werden.' + ); + } finally { + setSaving(false); + } + }; + + const f = (field: keyof FormState) => ({ + value: form[field] as string, + onChange: (e: React.ChangeEvent) => + setForm((prev) => ({ ...prev, [field]: e.target.value })), + error: Boolean(fieldErrors[field]), + helperText: fieldErrors[field], + }); + + if (loading) { + return ( + + + + + + ); + } + + if (error) { + return ( + + + {error} + + + + ); + } + + return ( + + + + + + {isEditMode ? 'Fahrzeug bearbeiten' : 'Neues Fahrzeug erfassen'} + + + {saveError && {saveError}} + + + Stammdaten + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Status + + + + Status + + + + + + + + + Prüf- und Wartungsfristen + + + setForm((prev) => ({ ...prev, paragraph57a_faellig_am: e.target.value }))} + InputLabelProps={{ shrink: true }} + helperText="Periodische Begutachtung (§57a StVO)" + /> + + + setForm((prev) => ({ ...prev, naechste_wartung_am: e.target.value }))} + InputLabelProps={{ shrink: true }} + helperText="Nächster geplanter Servicetermin" + /> + + + + Bild + + + + + + + + + + + + + + ); +} + +export default FahrzeugForm; diff --git a/frontend/src/pages/Fahrzeuge.tsx b/frontend/src/pages/Fahrzeuge.tsx index 4056fb0..bd78795 100644 --- a/frontend/src/pages/Fahrzeuge.tsx +++ b/frontend/src/pages/Fahrzeuge.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState, useCallback } from 'react'; import { + Alert, Box, Card, CardActionArea, @@ -15,7 +16,6 @@ import { TextField, Tooltip, Typography, - Alert, } from '@mui/material'; import { Add, @@ -35,8 +35,6 @@ import { FahrzeugListItem, FahrzeugStatus, FahrzeugStatusLabel, - PruefungArt, - PruefungArtLabel, } from '../types/vehicle.types'; import { usePermissions } from '../hooks/usePermissions'; @@ -64,13 +62,23 @@ function inspBadgeColor(tage: number | null): InspBadgeColor { } function inspBadgeLabel(art: string, tage: number | null, faelligAm: string | null): string { - const artShort = art; // 'HU', 'AU', etc. if (faelligAm === null) return ''; - const date = new Date(faelligAm).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' }); - if (tage === null) return `${artShort}: ${date}`; - if (tage < 0) return `${artShort}: ÜBERFÄLLIG (${date})`; - if (tage === 0) return `${artShort}: heute (${date})`; - return `${artShort}: ${date}`; + const date = new Date(faelligAm).toLocaleDateString('de-DE', { + day: '2-digit', month: '2-digit', year: '2-digit', + }); + if (tage === null) return `${art}: ${date}`; + if (tage < 0) return `${art}: ÜBERFÄLLIG (${date})`; + if (tage === 0) return `${art}: heute (${date})`; + return `${art}: ${date}`; +} + +function inspTooltipTitle(fullLabel: string, tage: number | null, faelligAm: string | null): string { + if (!faelligAm) return fullLabel; + const date = new Date(faelligAm).toLocaleDateString('de-DE'); + if (tage !== null && tage < 0) { + return `${fullLabel}: Seit ${Math.abs(tage)} Tagen überfällig!`; + } + return `${fullLabel}: Fällig am ${date}`; } // ── Vehicle Card ────────────────────────────────────────────────────────────── @@ -81,15 +89,23 @@ interface VehicleCardProps { } const VehicleCard: React.FC = ({ vehicle, onClick }) => { - const status = vehicle.status as FahrzeugStatus; + const status = vehicle.status as FahrzeugStatus; const statusCfg = STATUS_CONFIG[status] ?? STATUS_CONFIG[FahrzeugStatus.Einsatzbereit]; - const isSchaden = status === FahrzeugStatus.AusserDienstSchaden; - // Collect inspection badges (only for types where a faellig_am exists) - const inspBadges: { art: string; tage: number | null; faelligAm: string | null }[] = [ - { art: '§57a', tage: vehicle.paragraph57a_tage_bis_faelligkeit, faelligAm: vehicle.paragraph57a_faellig_am }, - { art: 'Wartung', tage: vehicle.wartung_tage_bis_faelligkeit, faelligAm: vehicle.naechste_wartung_am }, + const inspBadges = [ + { + art: '§57a', + fullLabel: '§57a Periodische Prüfung', + tage: vehicle.paragraph57a_tage_bis_faelligkeit, + faelligAm: vehicle.paragraph57a_faellig_am, + }, + { + art: 'Wartung', + fullLabel: 'Nächste Wartung / Service', + tage: vehicle.wartung_tage_bis_faelligkeit, + faelligAm: vehicle.naechste_wartung_am, + }, ].filter((b) => b.faelligAm !== null); return ( @@ -116,7 +132,6 @@ const VehicleCard: React.FC = ({ vehicle, onClick }) => { onClick={() => onClick(vehicle.id)} sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }} > - {/* Vehicle image / placeholder */} {vehicle.bild_url ? ( = ({ vehicle, onClick }) => { )} - {/* Title row */} @@ -159,7 +173,6 @@ const VehicleCard: React.FC = ({ vehicle, onClick }) => { - {/* Status badge */} = ({ vehicle, onClick }) => { /> - {/* Crew config */} {vehicle.besatzung_soll && ( Besatzung: {vehicle.besatzung_soll} @@ -178,7 +190,6 @@ const VehicleCard: React.FC = ({ vehicle, onClick }) => { )} - {/* Inspection badges */} {inspBadges.length > 0 && ( {inspBadges.map((b) => { @@ -188,11 +199,7 @@ const VehicleCard: React.FC = ({ vehicle, onClick }) => { return ( v.status === FahrzeugStatus.Einsatzbereit).length; + + // An overdue inspection exists if §57a OR Wartung is past due const hasOverdue = vehicles.some( - (v) => v.naechste_pruefung_tage !== null && v.naechste_pruefung_tage < 0 + (v) => + (v.paragraph57a_tage_bis_faelligkeit !== null && v.paragraph57a_tage_bis_faelligkeit < 0) || + (v.wartung_tage_bis_faelligkeit !== null && v.wartung_tage_bis_faelligkeit < 0) ); return ( - {/* Header */} @@ -268,12 +277,7 @@ function Fahrzeuge() { {vehicles.length} Fahrzeug{vehicles.length !== 1 ? 'e' : ''} gesamt {' · '} - + {einsatzbereit} einsatzbereit @@ -281,15 +285,12 @@ function Fahrzeuge() { - {/* Overdue inspection global warning */} {hasOverdue && ( }> - Achtung: Mindestens ein Fahrzeug hat eine überfällige Prüfungsfrist. - Betroffene Fahrzeuge dürfen bis zur Durchführung der Prüfung ggf. nicht eingesetzt werden. + Achtung: Mindestens ein Fahrzeug hat eine überfällige Prüfungs- oder Wartungsfrist. )} - {/* Search bar */} - {/* Loading state */} {loading && ( )} - {/* Error state */} {!loading && error && ( {error} )} - {/* Empty state */} {!loading && !error && filtered.length === 0 && ( @@ -332,7 +330,6 @@ function Fahrzeuge() { )} - {/* Vehicle grid */} {!loading && !error && filtered.length > 0 && ( {filtered.map((vehicle) => ( @@ -346,7 +343,6 @@ function Fahrzeuge() { )} - {/* FAB — add vehicle (shown to write-role users only; role check done server-side) */} {isAdmin && ( (promise: ReturnType>): Promise { +async function unwrap( + promise: ReturnType> +): Promise { const response = await promise; return response.data.data; } -// --------------------------------------------------------------------------- -// Vehicle API Service -// --------------------------------------------------------------------------- - export const vehiclesApi = { - - // ── Fleet overview ────────────────────────────────────────────────────────── - - /** Fetch all vehicles with their next inspection badge data */ async getAll(): Promise { return unwrap(api.get<{ success: boolean; data: FahrzeugListItem[] }>('/api/vehicles')); }, - /** Dashboard KPI stats */ async getStats(): Promise { return unwrap(api.get<{ success: boolean; data: VehicleStats }>('/api/vehicles/stats')); }, - /** - * Upcoming and overdue inspection alerts. - * @param daysAhead How many days to look ahead (default 30, max 365). - */ async getAlerts(daysAhead = 30): Promise { return unwrap( api.get<{ success: boolean; data: InspectionAlert[] }>( @@ -51,15 +35,10 @@ export const vehiclesApi = { ); }, - // ── Vehicle detail ────────────────────────────────────────────────────────── - - /** Full vehicle detail including inspection history and maintenance log */ async getById(id: string): Promise { return unwrap(api.get<{ success: boolean; data: FahrzeugDetail }>(`/api/vehicles/${id}`)); }, - // ── CRUD ──────────────────────────────────────────────────────────────────── - async create(payload: CreateFahrzeugPayload): Promise { const response = await api.post<{ success: boolean; data: FahrzeugDetail }>( '/api/vehicles', @@ -80,29 +59,10 @@ export const vehiclesApi = { await api.delete(`/api/vehicles/${id}`); }, - /** Live status change — Socket.IO event is emitted server-side in Tier 3 */ async updateStatus(id: string, payload: UpdateStatusPayload): Promise { await api.patch(`/api/vehicles/${id}/status`, payload); }, - // ── Inspections ───────────────────────────────────────────────────────────── - - async getPruefungen(id: string): Promise { - return unwrap( - api.get<{ success: boolean; data: FahrzeugPruefung[] }>(`/api/vehicles/${id}/pruefungen`) - ); - }, - - async addPruefung(id: string, payload: CreatePruefungPayload): Promise { - const response = await api.post<{ success: boolean; data: FahrzeugPruefung }>( - `/api/vehicles/${id}/pruefungen`, - payload - ); - return response.data.data; - }, - - // ── Maintenance log ───────────────────────────────────────────────────────── - async getWartungslog(id: string): Promise { return unwrap( api.get<{ success: boolean; data: FahrzeugWartungslog[] }>(`/api/vehicles/${id}/wartung`) diff --git a/frontend/src/types/vehicle.types.ts b/frontend/src/types/vehicle.types.ts index 5d3c33e..952907c 100644 --- a/frontend/src/types/vehicle.types.ts +++ b/frontend/src/types/vehicle.types.ts @@ -1,6 +1,5 @@ // ============================================================================= // Vehicle Fleet Management — Frontend Type Definitions -// Mirror of backend/src/models/vehicle.model.ts (transport layer shapes) // ============================================================================= export enum FahrzeugStatus { @@ -17,32 +16,6 @@ export const FahrzeugStatusLabel: Record = { [FahrzeugStatus.InLehrgang]: 'In Lehrgang', }; -export enum PruefungArt { - HU = 'HU', - AU = 'AU', - UVV = 'UVV', - Leiter = 'Leiter', - Kran = 'Kran', - Seilwinde = 'Seilwinde', - Sonstiges = 'Sonstiges', -} - -export const PruefungArtLabel: Record = { - [PruefungArt.HU]: 'Hauptuntersuchung (TÜV)', - [PruefungArt.AU]: 'Abgasuntersuchung', - [PruefungArt.UVV]: 'UVV-Prüfung (BGV D29)', - [PruefungArt.Leiter]: 'Leiternprüfung (DLK)', - [PruefungArt.Kran]: 'Kranprüfung', - [PruefungArt.Seilwinde]: 'Seilwindenprüfung', - [PruefungArt.Sonstiges]: 'Sonstige Prüfung', -}; - -export type PruefungErgebnis = - | 'bestanden' - | 'bestanden_mit_maengeln' - | 'nicht_bestanden' - | 'ausstehend'; - export type WartungslogArt = | 'Inspektion' | 'Reparatur' @@ -65,14 +38,6 @@ export interface FahrzeugListItem { status: FahrzeugStatus; status_bemerkung: string | null; bild_url: string | null; - hu_faellig_am: string | null; // ISO date string from API - hu_tage_bis_faelligkeit: number | null; - au_faellig_am: string | null; - au_tage_bis_faelligkeit: number | null; - uvv_faellig_am: string | null; - uvv_tage_bis_faelligkeit: number | null; - leiter_faellig_am: string | null; - leiter_tage_bis_faelligkeit: number | null; paragraph57a_faellig_am: string | null; paragraph57a_tage_bis_faelligkeit: number | null; naechste_wartung_am: string | null; @@ -80,29 +45,6 @@ export interface FahrzeugListItem { naechste_pruefung_tage: number | null; } -export interface PruefungStatus { - pruefung_id: string | null; - faellig_am: string | null; - tage_bis_faelligkeit: number | null; - ergebnis: PruefungErgebnis | null; -} - -export interface FahrzeugPruefung { - id: string; - fahrzeug_id: string; - pruefung_art: PruefungArt; - faellig_am: string; - durchgefuehrt_am: string | null; - ergebnis: PruefungErgebnis | null; - naechste_faelligkeit: string | null; - pruefende_stelle: string | null; - kosten: number | null; - dokument_url: string | null; - bemerkung: string | null; - erfasst_von: string | null; - created_at: string; -} - export interface FahrzeugWartungslog { id: string; fahrzeug_id: string; @@ -137,14 +79,7 @@ export interface FahrzeugDetail { paragraph57a_tage_bis_faelligkeit: number | null; naechste_wartung_am: string | null; wartung_tage_bis_faelligkeit: number | null; - pruefstatus: { - hu: PruefungStatus; - au: PruefungStatus; - uvv: PruefungStatus; - leiter: PruefungStatus; - }; - naechste_pruefung_tage: number | null; - pruefungen: FahrzeugPruefung[]; + naechste_pruefung_tage: number | null; wartungslog: FahrzeugWartungslog[]; } @@ -157,12 +92,13 @@ export interface VehicleStats { inspectionsOverdue: number; } +export type InspectionAlertType = '57a' | 'wartung'; + export interface InspectionAlert { fahrzeugId: string; bezeichnung: string; kurzname: string | null; - pruefungId: string; - pruefungArt: PruefungArt; + type: InspectionAlertType; faelligAm: string; tage: number; } @@ -186,24 +122,15 @@ export interface CreateFahrzeugPayload { naechste_wartung_am?: string; } -export type UpdateFahrzeugPayload = Partial; +export type UpdateFahrzeugPayload = { + [K in keyof CreateFahrzeugPayload]?: CreateFahrzeugPayload[K] | null; +}; export interface UpdateStatusPayload { status: FahrzeugStatus; bemerkung?: string; } -export interface CreatePruefungPayload { - pruefung_art: PruefungArt; - faellig_am: string; - durchgefuehrt_am?: string; - ergebnis?: PruefungErgebnis; - pruefende_stelle?: string; - kosten?: number; - dokument_url?: string; - bemerkung?: string; -} - export interface CreateWartungslogPayload { datum: string; art?: WartungslogArt;