From e2be29c712dcd33d0d3da5b70f1483c3ff5071d3 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Sat, 28 Feb 2026 17:19:18 +0100 Subject: [PATCH] refine vehicle freatures --- backend/src/app.ts | 2 + .../src/controllers/equipment.controller.ts | 318 ++++++++ .../migrations/011_create_ausruestung.sql | 143 ++++ backend/src/models/equipment.model.ts | 175 ++++ backend/src/routes/equipment.routes.ts | 32 + backend/src/services/equipment.service.ts | 414 ++++++++++ frontend/src/App.tsx | 26 + frontend/src/hooks/usePermissions.ts | 3 +- frontend/src/pages/Ausruestung.tsx | 514 ++++++++++-- frontend/src/pages/AusruestungDetail.tsx | 762 ++++++++++++++++++ frontend/src/pages/AusruestungForm.tsx | 521 ++++++++++++ frontend/src/pages/FahrzeugDetail.tsx | 223 ++++- frontend/src/pages/FahrzeugForm.tsx | 73 +- frontend/src/pages/Fahrzeuge.tsx | 47 +- frontend/src/services/equipment.ts | 111 +++ frontend/src/types/equipment.types.ts | 136 ++++ .../2026-02-28-vehicle-equipment-features.md | 688 ++++++++++++++++ 17 files changed, 4071 insertions(+), 117 deletions(-) create mode 100644 backend/src/controllers/equipment.controller.ts create mode 100644 backend/src/database/migrations/011_create_ausruestung.sql create mode 100644 backend/src/models/equipment.model.ts create mode 100644 backend/src/routes/equipment.routes.ts create mode 100644 backend/src/services/equipment.service.ts create mode 100644 frontend/src/pages/AusruestungDetail.tsx create mode 100644 frontend/src/pages/AusruestungForm.tsx create mode 100644 frontend/src/services/equipment.ts create mode 100644 frontend/src/types/equipment.types.ts create mode 100644 plans/2026-02-28-vehicle-equipment-features.md diff --git a/backend/src/app.ts b/backend/src/app.ts index ed53a8b..e8c81c3 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -60,6 +60,7 @@ import adminRoutes from './routes/admin.routes'; import trainingRoutes from './routes/training.routes'; import vehicleRoutes from './routes/vehicle.routes'; import incidentRoutes from './routes/incident.routes'; +import equipmentRoutes from './routes/equipment.routes'; app.use('/api/auth', authRoutes); app.use('/api/user', userRoutes); @@ -68,6 +69,7 @@ app.use('/api/admin', adminRoutes); app.use('/api/training', trainingRoutes); app.use('/api/vehicles', vehicleRoutes); app.use('/api/incidents', incidentRoutes); +app.use('/api/equipment', equipmentRoutes); // 404 handler app.use(notFoundHandler); diff --git a/backend/src/controllers/equipment.controller.ts b/backend/src/controllers/equipment.controller.ts new file mode 100644 index 0000000..9b62832 --- /dev/null +++ b/backend/src/controllers/equipment.controller.ts @@ -0,0 +1,318 @@ +import { Request, Response } from 'express'; +import { z } from 'zod'; +import equipmentService from '../services/equipment.service'; +import { AusruestungStatus } from '../models/equipment.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 AusruestungStatusEnum = z.enum([ + AusruestungStatus.Einsatzbereit, + AusruestungStatus.Beschaedigt, + AusruestungStatus.InWartung, + AusruestungStatus.AusserDienst, +]); + +const isoDate = z.string().regex( + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/, + 'Erwartet ISO-Datum im Format YYYY-MM-DD' +); + +const uuidString = z.string().regex( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + 'Ungültige UUID' +); + +const CreateAusruestungSchema = z.object({ + bezeichnung: z.string().min(1).max(200), + kategorie_id: uuidString, + seriennummer: z.string().max(100).optional(), + inventarnummer: z.string().max(50).optional(), + hersteller: z.string().max(150).optional(), + baujahr: z.number().int().min(1950).max(2100).optional(), + status: AusruestungStatusEnum.optional(), + status_bemerkung: z.string().max(2000).optional(), + ist_wichtig: z.boolean().optional(), + fahrzeug_id: uuidString.optional(), + standort: z.string().min(1).max(150).optional(), + pruef_intervall_monate: z.number().int().min(1).optional(), + letzte_pruefung_am: isoDate.optional(), + naechste_pruefung_am: isoDate.optional(), + bemerkung: z.string().max(2000).optional(), +}); + +const UpdateAusruestungSchema = z.object({ + bezeichnung: z.string().min(1).max(200).optional(), + kategorie_id: uuidString.optional(), + seriennummer: z.string().max(100).nullable().optional(), + inventarnummer: z.string().max(50).nullable().optional(), + hersteller: z.string().max(150).nullable().optional(), + baujahr: z.number().int().min(1950).max(2100).nullable().optional(), + status: AusruestungStatusEnum.optional(), + status_bemerkung: z.string().max(2000).nullable().optional(), + ist_wichtig: z.boolean().optional(), + fahrzeug_id: uuidString.nullable().optional(), + standort: z.string().min(1).max(150).optional(), + pruef_intervall_monate: z.number().int().min(1).nullable().optional(), + letzte_pruefung_am: isoDate.nullable().optional(), + naechste_pruefung_am: isoDate.nullable().optional(), + bemerkung: z.string().max(2000).nullable().optional(), +}); + +const UpdateStatusSchema = z.object({ + status: AusruestungStatusEnum, + bemerkung: z.string().max(2000).optional().default(''), +}); + +const CreateWartungslogSchema = z.object({ + datum: isoDate, + art: z.enum(['Prüfung', 'Reparatur', 'Sonstiges']), + beschreibung: z.string().min(1).max(2000), + ergebnis: z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']).optional(), + kosten: z.number().min(0).optional(), + pruefende_stelle: z.string().max(150).optional(), + dokument_url: z.string().url().max(500).refine( + (url) => /^https?:\/\//i.test(url), + 'Nur http/https URLs erlaubt' + ).optional(), +}); + +// ── Helper ──────────────────────────────────────────────────────────────────── + +function getUserId(req: Request): string { + return req.user!.id; +} + +// ── Controller ──────────────────────────────────────────────────────────────── + +class EquipmentController { + async listEquipment(_req: Request, res: Response): Promise { + try { + const equipment = await equipmentService.getAllEquipment(); + res.status(200).json({ success: true, data: equipment }); + } catch (error) { + logger.error('listEquipment error', { error }); + res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht geladen werden' }); + } + } + + async getEquipment(req: Request, res: Response): Promise { + try { + const { id } = req.params as Record; + if (!isValidUUID(id)) { + res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-ID' }); + return; + } + const equipment = await equipmentService.getEquipmentById(id); + if (!equipment) { + res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: equipment }); + } catch (error) { + logger.error('getEquipment error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht geladen werden' }); + } + } + + async getByVehicle(req: Request, res: Response): Promise { + try { + const { fahrzeugId } = req.params as Record; + if (!isValidUUID(fahrzeugId)) { + res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' }); + return; + } + const equipment = await equipmentService.getEquipmentByVehicle(fahrzeugId); + res.status(200).json({ success: true, data: equipment }); + } catch (error) { + logger.error('getByVehicle error', { error, fahrzeugId: req.params.fahrzeugId }); + res.status(500).json({ success: false, message: 'Ausrüstung für Fahrzeug konnte nicht geladen werden' }); + } + } + + async getCategories(_req: Request, res: Response): Promise { + try { + const categories = await equipmentService.getCategories(); + res.status(200).json({ success: true, data: categories }); + } catch (error) { + logger.error('getCategories error', { error }); + res.status(500).json({ success: false, message: 'Kategorien konnten nicht geladen werden' }); + } + } + + async getStats(_req: Request, res: Response): Promise { + try { + const stats = await equipmentService.getEquipmentStats(); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + logger.error('getStats error', { error }); + res.status(500).json({ success: false, message: 'Statistiken konnten nicht geladen werden' }); + } + } + + async getAlerts(req: Request, res: Response): Promise { + try { + 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 equipmentService.getUpcomingInspections(daysAhead); + res.status(200).json({ success: true, data: alerts }); + } catch (error) { + logger.error('getAlerts error', { error }); + res.status(500).json({ success: false, message: 'Prüfungshinweise konnten nicht geladen werden' }); + } + } + + async getVehicleWarnings(_req: Request, res: Response): Promise { + try { + const warnings = await equipmentService.getVehicleWarnings(); + res.status(200).json({ success: true, data: warnings }); + } catch (error) { + logger.error('getVehicleWarnings error', { error }); + res.status(500).json({ success: false, message: 'Fahrzeug-Warnungen konnten nicht geladen werden' }); + } + } + + async createEquipment(req: Request, res: Response): Promise { + try { + const parsed = CreateAusruestungSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ + success: false, + message: 'Validierungsfehler', + errors: parsed.error.flatten().fieldErrors, + }); + return; + } + const equipment = await equipmentService.createEquipment(parsed.data, getUserId(req)); + res.status(201).json({ success: true, data: equipment }); + } catch (error) { + logger.error('createEquipment error', { error }); + res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht erstellt werden' }); + } + } + + async updateEquipment(req: Request, res: Response): Promise { + try { + const { id } = req.params as Record; + if (!isValidUUID(id)) { + res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-ID' }); + return; + } + const parsed = UpdateAusruestungSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ + success: false, + message: 'Validierungsfehler', + errors: parsed.error.flatten().fieldErrors, + }); + return; + } + if (Object.keys(parsed.data).length === 0) { + res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' }); + return; + } + const equipment = await equipmentService.updateEquipment(id, parsed.data, getUserId(req)); + if (!equipment) { + res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: equipment }); + } catch (error: any) { + if (error?.message === 'No fields to update') { + res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' }); + return; + } + logger.error('updateEquipment error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht aktualisiert werden' }); + } + } + + async updateStatus(req: Request, res: Response): Promise { + try { + const { id } = req.params as Record; + if (!isValidUUID(id)) { + res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-ID' }); + return; + } + const parsed = UpdateStatusSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ + success: false, + message: 'Validierungsfehler', + errors: parsed.error.flatten().fieldErrors, + }); + return; + } + await equipmentService.updateStatus( + id, parsed.data.status, parsed.data.bemerkung, getUserId(req) + ); + res.status(200).json({ success: true, message: 'Status aktualisiert' }); + } catch (error: any) { + if (error?.message === 'Equipment not found') { + res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' }); + return; + } + logger.error('updateStatus error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Status konnte nicht aktualisiert werden' }); + } + } + + async deleteEquipment(req: Request, res: Response): Promise { + try { + const { id } = req.params as Record; + if (!isValidUUID(id)) { + res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-ID' }); + return; + } + const deleted = await equipmentService.deleteEquipment(id, getUserId(req)); + if (!deleted) { + res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' }); + return; + } + res.status(200).json({ success: true, message: 'Ausrüstung gelöscht' }); + } catch (error) { + logger.error('deleteEquipment error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht gelöscht werden' }); + } + } + + 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 Ausrüstungs-ID' }); + return; + } + const parsed = CreateWartungslogSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ + success: false, + message: 'Validierungsfehler', + errors: parsed.error.flatten().fieldErrors, + }); + return; + } + const entry = await equipmentService.addWartungslog(id, parsed.data, getUserId(req)); + res.status(201).json({ success: true, data: entry }); + } catch (error: any) { + if (error?.message === 'Equipment not found') { + res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' }); + return; + } + logger.error('addWartung error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht gespeichert werden' }); + } + } +} + +export default new EquipmentController(); diff --git a/backend/src/database/migrations/011_create_ausruestung.sql b/backend/src/database/migrations/011_create_ausruestung.sql new file mode 100644 index 0000000..e7a8a60 --- /dev/null +++ b/backend/src/database/migrations/011_create_ausruestung.sql @@ -0,0 +1,143 @@ +-- Migration 011: Ausrüstungsverwaltung (Equipment Management) +-- Depends on: 001_create_users_table.sql (uuid-ossp extension, users table) +-- 005_create_fahrzeuge.sql (fahrzeuge table) +-- 009_vehicle_soft_delete.sql (soft-delete pattern) + +-- ============================================================ +-- TABLE: ausruestung_kategorien (Equipment Categories — lookup) +-- ============================================================ +CREATE TABLE IF NOT EXISTS ausruestung_kategorien ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(100) NOT NULL UNIQUE, -- e.g. 'Atemschutzgeräte' + kurzname VARCHAR(30) NOT NULL UNIQUE, -- e.g. 'PA' + sortierung INTEGER NOT NULL DEFAULT 0, -- display order + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + + +-- ============================================================ +-- TABLE: ausruestung (Core Equipment Inventory) +-- ============================================================ +CREATE TABLE IF NOT EXISTS ausruestung ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + bezeichnung VARCHAR(200) NOT NULL, -- e.g. 'Dräger PSS 5000' + kategorie_id UUID NOT NULL REFERENCES ausruestung_kategorien(id), + seriennummer VARCHAR(100), -- manufacturer serial + inventarnummer VARCHAR(50), -- internal inventory number + hersteller VARCHAR(150), -- manufacturer + baujahr INTEGER CHECK (baujahr >= 1950 AND baujahr <= 2100), + status VARCHAR(30) NOT NULL DEFAULT 'einsatzbereit' + CHECK (status IN ( + 'einsatzbereit', + 'beschaedigt', + 'in_wartung', + 'ausser_dienst' + )), + status_bemerkung TEXT, -- free-text status note + ist_wichtig BOOLEAN NOT NULL DEFAULT FALSE, -- drives vehicle card warnings + fahrzeug_id UUID REFERENCES fahrzeuge(id) ON DELETE SET NULL, -- nullable + standort VARCHAR(150) NOT NULL DEFAULT 'Lager', -- used when no fahrzeug + pruef_intervall_monate INTEGER CHECK (pruef_intervall_monate > 0), -- nullable + letzte_pruefung_am DATE, + naechste_pruefung_am DATE, + bemerkung TEXT, -- general notes + deleted_at TIMESTAMPTZ, -- soft-delete + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ausruestung_status + ON ausruestung(status); +CREATE INDEX IF NOT EXISTS idx_ausruestung_kategorie + ON ausruestung(kategorie_id); +CREATE INDEX IF NOT EXISTS idx_ausruestung_fahrzeug + ON ausruestung(fahrzeug_id) + WHERE fahrzeug_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_ausruestung_active + ON ausruestung(id) + WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_ausruestung_pruefung + ON ausruestung(naechste_pruefung_am) + WHERE naechste_pruefung_am IS NOT NULL AND deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_ausruestung_wichtig + ON ausruestung(fahrzeug_id, status) + WHERE ist_wichtig = TRUE AND deleted_at IS NULL; + +-- Auto-update updated_at (reuses function from migration 001) +CREATE TRIGGER update_ausruestung_updated_at + BEFORE UPDATE ON ausruestung + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + + +-- ============================================================ +-- TABLE: ausruestung_wartungslog (Service/Inspection History) +-- ============================================================ +CREATE TABLE IF NOT EXISTS ausruestung_wartungslog ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + ausruestung_id UUID NOT NULL REFERENCES ausruestung(id) ON DELETE CASCADE, + datum DATE NOT NULL, + art VARCHAR(30) NOT NULL + CHECK (art IN ( + 'Prüfung', + 'Reparatur', + 'Sonstiges' + )), + beschreibung TEXT NOT NULL, + ergebnis VARCHAR(30) + CHECK (ergebnis IS NULL OR ergebnis IN ( + 'bestanden', + 'bestanden_mit_maengeln', + 'nicht_bestanden' + )), + kosten DECIMAL(8,2) CHECK (kosten >= 0), + pruefende_stelle VARCHAR(150), -- e.g. 'Atemschutzwerkstatt Bezirk' + dokument_url VARCHAR(500), -- link to scan/PDF + erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ausruestung_wartung_item + ON ausruestung_wartungslog(ausruestung_id); +CREATE INDEX IF NOT EXISTS idx_ausruestung_wartung_datum + ON ausruestung_wartungslog(datum DESC); + + +-- ============================================================ +-- VIEW: ausruestung_mit_pruefstatus +-- For each active equipment item, joins category and vehicle +-- and computes pruefung_tage_bis_faelligkeit (negative = overdue). +-- The dashboard equipment panel and fleet overview query this view. +-- ============================================================ +CREATE OR REPLACE VIEW ausruestung_mit_pruefstatus AS +SELECT + a.*, + k.name AS kategorie_name, + k.kurzname AS kategorie_kurzname, + f.bezeichnung AS fahrzeug_bezeichnung, + f.kurzname AS fahrzeug_kurzname, + CASE + WHEN a.naechste_pruefung_am IS NOT NULL + THEN a.naechste_pruefung_am::date - CURRENT_DATE + ELSE NULL + END AS pruefung_tage_bis_faelligkeit +FROM ausruestung a +JOIN ausruestung_kategorien k ON k.id = a.kategorie_id +LEFT JOIN fahrzeuge f ON f.id = a.fahrzeug_id AND f.deleted_at IS NULL +WHERE a.deleted_at IS NULL; + + +-- ============================================================ +-- SEED DATA: Equipment Categories +-- ============================================================ +INSERT INTO ausruestung_kategorien (name, kurzname, sortierung) VALUES + ('Atemschutzgeräte', 'PA', 1), + ('Pumpen', 'Pumpe', 2), + ('Schläuche', 'SL', 3), + ('Leitern', 'Leiter', 4), + ('Rettungsgeräte', 'RG', 5), + ('Messgeräte', 'MG', 6), + ('Persönliche Schutzausrüstung', 'PSA', 7), + ('Kommunikation', 'Funk', 8), + ('Beleuchtung', 'Licht', 9), + ('Sonstige', 'Sonst.', 10) +ON CONFLICT (name) DO NOTHING; diff --git a/backend/src/models/equipment.model.ts b/backend/src/models/equipment.model.ts new file mode 100644 index 0000000..cb2dd9d --- /dev/null +++ b/backend/src/models/equipment.model.ts @@ -0,0 +1,175 @@ +// ============================================================================= +// Equipment Management — Domain Model +// ============================================================================= + +// ── Enums ───────────────────────────────────────────────────────────────────── + +export enum AusruestungStatus { + Einsatzbereit = 'einsatzbereit', + Beschaedigt = 'beschaedigt', + InWartung = 'in_wartung', + AusserDienst = 'ausser_dienst', +} + +export const AusruestungStatusLabel: Record = { + [AusruestungStatus.Einsatzbereit]: 'Einsatzbereit', + [AusruestungStatus.Beschaedigt]: 'Beschädigt', + [AusruestungStatus.InWartung]: 'In Wartung', + [AusruestungStatus.AusserDienst]: 'Außer Dienst', +}; + +export type AusruestungWartungslogArt = 'Prüfung' | 'Reparatur' | 'Sonstiges'; + +// ── Lookup Entity ───────────────────────────────────────────────────────────── + +export interface AusruestungKategorie { + id: string; + name: string; + kurzname: string; + sortierung: number; +} + +// ── Core Entity ─────────────────────────────────────────────────────────────── + +export interface Ausruestung { + id: string; + bezeichnung: string; + kategorie_id: string; + seriennummer: string | null; + inventarnummer: string | null; + hersteller: string | null; + baujahr: number | null; + status: AusruestungStatus; + status_bemerkung: string | null; + ist_wichtig: boolean; + fahrzeug_id: string | null; + standort: string; + pruef_intervall_monate: number | null; + letzte_pruefung_am: Date | null; + naechste_pruefung_am: Date | null; + bemerkung: string | null; + deleted_at: Date | null; + created_at: Date; + updated_at: Date; +} + +// ── List Item (Grid / Card view) ────────────────────────────────────────────── + +export interface AusruestungListItem { + id: string; + bezeichnung: string; + kategorie_id: string; + seriennummer: string | null; + inventarnummer: string | null; + hersteller: string | null; + baujahr: number | null; + status: AusruestungStatus; + status_bemerkung: string | null; + ist_wichtig: boolean; + fahrzeug_id: string | null; + standort: string; + pruef_intervall_monate: number | null; + letzte_pruefung_am: Date | null; + naechste_pruefung_am: Date | null; + bemerkung: string | null; + created_at: Date; + updated_at: Date; + kategorie_name: string; + kategorie_kurzname: string; + fahrzeug_bezeichnung: string | null; + fahrzeug_kurzname: string | null; + pruefung_tage_bis_faelligkeit: number | null; +} + +// ── Detail View ─────────────────────────────────────────────────────────────── + +export interface AusruestungDetail extends AusruestungListItem { + wartungslog: AusruestungWartungslog[]; +} + +// ── Wartungslog Entity ──────────────────────────────────────────────────────── + +export interface AusruestungWartungslog { + id: string; + ausruestung_id: string; + datum: Date; + art: AusruestungWartungslogArt; + beschreibung: string; + ergebnis: string | null; + kosten: number | null; + pruefende_stelle: string | null; + dokument_url: string | null; + erfasst_von: string | null; + created_at: Date; +} + +// ── Dashboard KPI ───────────────────────────────────────────────────────────── + +export interface EquipmentStats { + total: number; + einsatzbereit: number; + beschaedigt: number; + inWartung: number; + ausserDienst: number; + inspectionsDue: number; + inspectionsOverdue: number; + wichtigNichtBereit: number; +} + +// ── Vehicle Equipment Warning ───────────────────────────────────────────────── + +export interface VehicleEquipmentWarning { + fahrzeug_id: string; + ausruestung_id: string; + bezeichnung: string; + status: AusruestungStatus; + kategorie_name: string; +} + +// ── Create / Update DTOs ────────────────────────────────────────────────────── + +export interface CreateAusruestungData { + bezeichnung: string; + kategorie_id: string; + seriennummer?: string; + inventarnummer?: string; + hersteller?: string; + baujahr?: number; + status?: AusruestungStatus; + status_bemerkung?: string; + ist_wichtig?: boolean; + fahrzeug_id?: string; + standort?: string; + pruef_intervall_monate?: number; + letzte_pruefung_am?: string; + naechste_pruefung_am?: string; + bemerkung?: string; +} + +export interface UpdateAusruestungData { + bezeichnung?: string; + kategorie_id?: string; + seriennummer?: string | null; + inventarnummer?: string | null; + hersteller?: string | null; + baujahr?: number | null; + status?: AusruestungStatus; + status_bemerkung?: string | null; + ist_wichtig?: boolean; + fahrzeug_id?: string | null; + standort?: string; + pruef_intervall_monate?: number | null; + letzte_pruefung_am?: string | null; + naechste_pruefung_am?: string | null; + bemerkung?: string | null; +} + +export interface CreateAusruestungWartungslogData { + datum: string; + art: AusruestungWartungslogArt; + beschreibung: string; + ergebnis?: string; + kosten?: number; + pruefende_stelle?: string; + dokument_url?: string; +} diff --git a/backend/src/routes/equipment.routes.ts b/backend/src/routes/equipment.routes.ts new file mode 100644 index 0000000..da4aa70 --- /dev/null +++ b/backend/src/routes/equipment.routes.ts @@ -0,0 +1,32 @@ +import { Router } from 'express'; +import equipmentController from '../controllers/equipment.controller'; +import { authenticate } from '../middleware/auth.middleware'; +import { requireGroups } from '../middleware/rbac.middleware'; + +const ADMIN_GROUPS = ['dashboard_admin']; +const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister']; + +const router = Router(); + +// ── Read-only (any authenticated user) ─────────────────────────────────────── + +router.get('/', authenticate, equipmentController.listEquipment.bind(equipmentController)); +router.get('/stats', authenticate, equipmentController.getStats.bind(equipmentController)); +router.get('/alerts', authenticate, equipmentController.getAlerts.bind(equipmentController)); +router.get('/categories', authenticate, equipmentController.getCategories.bind(equipmentController)); +router.get('/vehicle-warnings', authenticate, equipmentController.getVehicleWarnings.bind(equipmentController)); +router.get('/vehicle/:fahrzeugId', authenticate, equipmentController.getByVehicle.bind(equipmentController)); +router.get('/:id', authenticate, equipmentController.getEquipment.bind(equipmentController)); + +// ── Write — admin + fahrmeister ────────────────────────────────────────────── + +router.post('/', authenticate, requireGroups(WRITE_GROUPS), equipmentController.createEquipment.bind(equipmentController)); +router.patch('/:id', authenticate, requireGroups(WRITE_GROUPS), equipmentController.updateEquipment.bind(equipmentController)); +router.patch('/:id/status', authenticate, requireGroups(WRITE_GROUPS), equipmentController.updateStatus.bind(equipmentController)); +router.post('/:id/wartung', authenticate, requireGroups(WRITE_GROUPS), equipmentController.addWartung.bind(equipmentController)); + +// ── Delete — admin only ────────────────────────────────────────────────────── + +router.delete('/:id', authenticate, requireGroups(ADMIN_GROUPS), equipmentController.deleteEquipment.bind(equipmentController)); + +export default router; diff --git a/backend/src/services/equipment.service.ts b/backend/src/services/equipment.service.ts new file mode 100644 index 0000000..6f8c1dc --- /dev/null +++ b/backend/src/services/equipment.service.ts @@ -0,0 +1,414 @@ +import pool from '../config/database'; +import logger from '../utils/logger'; +import { + Ausruestung, + AusruestungKategorie, + AusruestungListItem, + AusruestungDetail, + AusruestungWartungslog, + CreateAusruestungData, + UpdateAusruestungData, + CreateAusruestungWartungslogData, + AusruestungStatus, + EquipmentStats, + VehicleEquipmentWarning, +} from '../models/equipment.model'; + +class EquipmentService { + // ========================================================================= + // EQUIPMENT OVERVIEW + // ========================================================================= + + async getAllEquipment(): Promise { + try { + const result = await pool.query(` + SELECT * + FROM ausruestung_mit_pruefstatus + ORDER BY kategorie_kurzname, bezeichnung + `); + + return result.rows.map((row) => ({ + ...row, + pruefung_tage_bis_faelligkeit: row.pruefung_tage_bis_faelligkeit != null + ? parseInt(row.pruefung_tage_bis_faelligkeit, 10) : null, + })) as AusruestungListItem[]; + } catch (error) { + logger.error('EquipmentService.getAllEquipment failed', { error }); + throw new Error('Failed to fetch equipment'); + } + } + + // ========================================================================= + // EQUIPMENT DETAIL + // ========================================================================= + + async getEquipmentById(id: string): Promise { + try { + const equipmentResult = await pool.query( + `SELECT * FROM ausruestung_mit_pruefstatus WHERE id = $1`, + [id] + ); + + if (equipmentResult.rows.length === 0) return null; + + const row = equipmentResult.rows[0]; + + const wartungslogResult = await pool.query( + `SELECT * FROM ausruestung_wartungslog + WHERE ausruestung_id = $1 + ORDER BY datum DESC, created_at DESC`, + [id] + ); + + const equipment: AusruestungDetail = { + ...row, + pruefung_tage_bis_faelligkeit: row.pruefung_tage_bis_faelligkeit != null + ? parseInt(row.pruefung_tage_bis_faelligkeit, 10) : null, + wartungslog: wartungslogResult.rows.map(r => ({ + ...r, + kosten: r.kosten != null ? Number(r.kosten) : null, + })) as AusruestungWartungslog[], + }; + + return equipment; + } catch (error) { + logger.error('EquipmentService.getEquipmentById failed', { error, id }); + throw new Error('Failed to fetch equipment'); + } + } + + // ========================================================================= + // EQUIPMENT BY VEHICLE + // ========================================================================= + + async getEquipmentByVehicle(fahrzeugId: string): Promise { + try { + const result = await pool.query( + `SELECT * + FROM ausruestung_mit_pruefstatus + WHERE fahrzeug_id = $1 + ORDER BY kategorie_kurzname, bezeichnung`, + [fahrzeugId] + ); + + return result.rows.map((row) => ({ + ...row, + pruefung_tage_bis_faelligkeit: row.pruefung_tage_bis_faelligkeit != null + ? parseInt(row.pruefung_tage_bis_faelligkeit, 10) : null, + })) as AusruestungListItem[]; + } catch (error) { + logger.error('EquipmentService.getEquipmentByVehicle failed', { error, fahrzeugId }); + throw new Error('Failed to fetch equipment for vehicle'); + } + } + + // ========================================================================= + // CATEGORIES + // ========================================================================= + + async getCategories(): Promise { + try { + const result = await pool.query( + `SELECT * FROM ausruestung_kategorien ORDER BY sortierung` + ); + return result.rows as AusruestungKategorie[]; + } catch (error) { + logger.error('EquipmentService.getCategories failed', { error }); + throw new Error('Failed to fetch equipment categories'); + } + } + + // ========================================================================= + // CRUD + // ========================================================================= + + async createEquipment(data: CreateAusruestungData, createdBy: string): Promise { + try { + const result = await pool.query( + `INSERT INTO ausruestung ( + id, bezeichnung, kategorie_id, seriennummer, inventarnummer, + hersteller, baujahr, status, status_bemerkung, ist_wichtig, + fahrzeug_id, standort, pruef_intervall_monate, + letzte_pruefung_am, naechste_pruefung_am, bemerkung + ) VALUES (uuid_generate_v4(),$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + RETURNING *`, + [ + data.bezeichnung, + data.kategorie_id, + data.seriennummer ?? null, + data.inventarnummer ?? null, + data.hersteller ?? null, + data.baujahr ?? null, + data.status ?? AusruestungStatus.Einsatzbereit, + data.status_bemerkung ?? null, + data.ist_wichtig ?? false, + data.fahrzeug_id ?? null, + data.standort ?? 'Lager', + data.pruef_intervall_monate ?? null, + data.letzte_pruefung_am ?? null, + data.naechste_pruefung_am ?? null, + data.bemerkung ?? null, + ] + ); + + const equipment = result.rows[0] as Ausruestung; + logger.info('Equipment created', { id: equipment.id, by: createdBy }); + return equipment; + } catch (error) { + logger.error('EquipmentService.createEquipment failed', { error, createdBy }); + throw new Error('Failed to create equipment'); + } + } + + async updateEquipment(id: string, data: UpdateAusruestungData, updatedBy: string): Promise { + try { + const fields: string[] = []; + const values: unknown[] = []; + let p = 1; + + const addField = (col: string, value: unknown) => { + fields.push(`${col} = $${p++}`); + values.push(value); + }; + + if (data.bezeichnung !== undefined) addField('bezeichnung', data.bezeichnung); + if (data.kategorie_id !== undefined) addField('kategorie_id', data.kategorie_id); + if (data.seriennummer !== undefined) addField('seriennummer', data.seriennummer); + if (data.inventarnummer !== undefined) addField('inventarnummer', data.inventarnummer); + if (data.hersteller !== undefined) addField('hersteller', data.hersteller); + if (data.baujahr !== undefined) addField('baujahr', data.baujahr); + if (data.status !== undefined) addField('status', data.status); + if (data.status_bemerkung !== undefined) addField('status_bemerkung', data.status_bemerkung); + if (data.ist_wichtig !== undefined) addField('ist_wichtig', data.ist_wichtig); + if (data.fahrzeug_id !== undefined) addField('fahrzeug_id', data.fahrzeug_id); + if (data.standort !== undefined) addField('standort', data.standort); + if (data.pruef_intervall_monate !== undefined) addField('pruef_intervall_monate', data.pruef_intervall_monate); + if (data.letzte_pruefung_am !== undefined) addField('letzte_pruefung_am', data.letzte_pruefung_am); + if (data.naechste_pruefung_am !== undefined) addField('naechste_pruefung_am', data.naechste_pruefung_am); + if (data.bemerkung !== undefined) addField('bemerkung', data.bemerkung); + + if (fields.length === 0) { + throw new Error('No fields to update'); + } + + values.push(id); + const result = await pool.query( + `UPDATE ausruestung SET ${fields.join(', ')} WHERE id = $${p} AND deleted_at IS NULL RETURNING *`, + values + ); + + if (result.rows.length === 0) { + return null; + } + + const equipment = result.rows[0] as Ausruestung; + logger.info('Equipment updated', { id, by: updatedBy }); + return equipment; + } catch (error) { + logger.error('EquipmentService.updateEquipment failed', { error, id, updatedBy }); + throw error; + } + } + + async deleteEquipment(id: string, deletedBy: string): Promise { + try { + const result = await pool.query( + `UPDATE ausruestung + SET deleted_at = NOW() + WHERE id = $1 AND deleted_at IS NULL + RETURNING id`, + [id] + ); + + if (result.rows.length === 0) { + return false; + } + + logger.info('Equipment soft-deleted', { id, by: deletedBy }); + return true; + } catch (error) { + logger.error('EquipmentService.deleteEquipment failed', { error, id }); + throw error; + } + } + + // ========================================================================= + // STATUS MANAGEMENT + // ========================================================================= + + async updateStatus( + id: string, + status: AusruestungStatus, + bemerkung: string, + updatedBy: string + ): Promise { + try { + const result = await pool.query( + `UPDATE ausruestung + SET status = $1, status_bemerkung = $2, updated_at = NOW() + WHERE id = $3 AND deleted_at IS NULL + RETURNING id`, + [status, bemerkung || null, id] + ); + + if (result.rows.length === 0) { + throw new Error('Equipment not found'); + } + + logger.info('Equipment status updated', { id, status, by: updatedBy }); + } catch (error) { + logger.error('EquipmentService.updateStatus failed', { error, id }); + throw error; + } + } + + // ========================================================================= + // MAINTENANCE LOG + // ========================================================================= + + async addWartungslog( + equipmentId: string, + data: CreateAusruestungWartungslogData, + createdBy: string + ): Promise { + try { + const check = await pool.query( + `SELECT 1 FROM ausruestung WHERE id = $1 AND deleted_at IS NULL`, + [equipmentId] + ); + if (check.rows.length === 0) { + throw new Error('Equipment not found'); + } + + const result = await pool.query( + `INSERT INTO ausruestung_wartungslog ( + ausruestung_id, datum, art, beschreibung, + ergebnis, kosten, pruefende_stelle, dokument_url, erfasst_von + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + RETURNING *`, + [ + equipmentId, + data.datum, + data.art, + data.beschreibung, + data.ergebnis ?? null, + data.kosten ?? null, + data.pruefende_stelle ?? null, + data.dokument_url ?? null, + createdBy, + ] + ); + + const entry = result.rows[0] as AusruestungWartungslog; + logger.info('Equipment wartungslog entry added', { entryId: entry.id, equipmentId, by: createdBy }); + return entry; + } catch (error) { + logger.error('EquipmentService.addWartungslog failed', { error, equipmentId }); + throw error; + } + } + + // ========================================================================= + // DASHBOARD KPI + // ========================================================================= + + async getEquipmentStats(): Promise { + try { + const result = await pool.query(` + SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE status = 'einsatzbereit') AS einsatzbereit, + COUNT(*) FILTER (WHERE status = 'beschaedigt') AS beschaedigt, + COUNT(*) FILTER (WHERE status = 'in_wartung') AS in_wartung, + COUNT(*) FILTER (WHERE status = 'ausser_dienst') AS ausser_dienst, + COUNT(*) FILTER ( + WHERE naechste_pruefung_am IS NOT NULL + AND naechste_pruefung_am::date - CURRENT_DATE BETWEEN 0 AND 30 + ) AS inspections_due, + COUNT(*) FILTER ( + WHERE naechste_pruefung_am IS NOT NULL + AND naechste_pruefung_am::date < CURRENT_DATE + ) AS inspections_overdue, + COUNT(*) FILTER ( + WHERE ist_wichtig = TRUE + AND status != 'einsatzbereit' + ) AS wichtig_nicht_bereit + FROM ausruestung + WHERE deleted_at IS NULL + `); + + const row = result.rows[0] ?? {}; + + return { + total: parseInt(row.total ?? '0', 10), + einsatzbereit: parseInt(row.einsatzbereit ?? '0', 10), + beschaedigt: parseInt(row.beschaedigt ?? '0', 10), + inWartung: parseInt(row.in_wartung ?? '0', 10), + ausserDienst: parseInt(row.ausser_dienst ?? '0', 10), + inspectionsDue: parseInt(row.inspections_due ?? '0', 10), + inspectionsOverdue: parseInt(row.inspections_overdue ?? '0', 10), + wichtigNichtBereit: parseInt(row.wichtig_nicht_bereit ?? '0', 10), + }; + } catch (error) { + logger.error('EquipmentService.getEquipmentStats failed', { error }); + throw new Error('Failed to fetch equipment stats'); + } + } + + // ========================================================================= + // VEHICLE WARNINGS + // ========================================================================= + + async getVehicleWarnings(): Promise { + try { + const result = await pool.query(` + SELECT + a.fahrzeug_id, + a.id AS ausruestung_id, + a.bezeichnung, + a.status, + k.name AS kategorie_name + FROM ausruestung a + JOIN ausruestung_kategorien k ON k.id = a.kategorie_id + WHERE a.ist_wichtig = TRUE + AND a.fahrzeug_id IS NOT NULL + AND a.status != 'einsatzbereit' + AND a.deleted_at IS NULL + ORDER BY a.fahrzeug_id + `); + + return result.rows as VehicleEquipmentWarning[]; + } catch (error) { + logger.error('EquipmentService.getVehicleWarnings failed', { error }); + throw new Error('Failed to fetch vehicle equipment warnings'); + } + } + + // ========================================================================= + // UPCOMING INSPECTIONS + // ========================================================================= + + async getUpcomingInspections(daysAhead: number = 30): Promise { + try { + const result = await pool.query( + `SELECT * + FROM ausruestung_mit_pruefstatus + WHERE pruefung_tage_bis_faelligkeit IS NOT NULL + AND pruefung_tage_bis_faelligkeit <= $1 + ORDER BY pruefung_tage_bis_faelligkeit ASC`, + [daysAhead] + ); + + return result.rows.map((row) => ({ + ...row, + pruefung_tage_bis_faelligkeit: row.pruefung_tage_bis_faelligkeit != null + ? parseInt(row.pruefung_tage_bis_faelligkeit, 10) : null, + })) as AusruestungListItem[]; + } catch (error) { + logger.error('EquipmentService.getUpcomingInspections failed', { error, daysAhead }); + throw new Error('Failed to fetch upcoming inspections'); + } + } +} + +export default new EquipmentService(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2059474..e5f188f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,8 @@ import Fahrzeuge from './pages/Fahrzeuge'; import FahrzeugDetail from './pages/FahrzeugDetail'; import FahrzeugForm from './pages/FahrzeugForm'; import Ausruestung from './pages/Ausruestung'; +import AusruestungForm from './pages/AusruestungForm'; +import AusruestungDetail from './pages/AusruestungDetail'; import Mitglieder from './pages/Mitglieder'; import MitgliedDetail from './pages/MitgliedDetail'; import Kalender from './pages/Kalender'; @@ -109,6 +111,30 @@ function App() { } /> + + + + } + /> + + + + } + /> + + + + } + /> = { + [AusruestungStatus.Einsatzbereit]: { color: 'success', icon: }, + [AusruestungStatus.Beschaedigt]: { color: 'error', icon: }, + [AusruestungStatus.InWartung]: { color: 'warning', icon: }, + [AusruestungStatus.AusserDienst]: { color: 'default', icon: }, +}; + +// ── Inspection badge helpers ────────────────────────────────────────────────── + +type InspBadgeColor = 'success' | 'warning' | 'error' | 'default'; + +function inspBadgeColor(tage: number | null): InspBadgeColor { + if (tage === null) return 'default'; + if (tage < 0) return 'error'; + if (tage <= 30) return 'warning'; + return 'success'; +} + +function inspBadgeLabel(tage: number | null, faelligAm: string | null): string { + 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 `Prüfung: ${date}`; + if (tage < 0) return `ÜBERFÄLLIG (${date})`; + if (tage === 0) return `Prüfung: heute`; + return `Prüfung: ${date}`; +} + +function inspTooltipTitle(tage: number | null, faelligAm: string | null): string { + if (!faelligAm) return 'Keine Prüfung geplant'; + const date = new Date(faelligAm).toLocaleDateString('de-DE'); + if (tage !== null && tage < 0) { + return `Prüfung seit ${Math.abs(tage)} Tagen überfällig!`; + } + if (tage !== null && tage === 0) { + return 'Prüfung heute fällig'; + } + if (tage !== null) { + return `Nächste Prüfung am ${date} (in ${tage} Tagen)`; + } + return `Nächste Prüfung am ${date}`; +} + +// ── Equipment Card ──────────────────────────────────────────────────────────── + +interface EquipmentCardProps { + item: AusruestungListItem; + onClick: (id: string) => void; +} + +const EquipmentCard: React.FC = ({ item, onClick }) => { + const status = item.status as AusruestungStatus; + const statusCfg = STATUS_CONFIG[status] ?? STATUS_CONFIG[AusruestungStatus.Einsatzbereit]; + const isBeschaedigt = status === AusruestungStatus.Beschaedigt; + + const pruefungLabel = inspBadgeLabel(item.pruefung_tage_bis_faelligkeit, item.naechste_pruefung_am); + const pruefungColor = inspBadgeColor(item.pruefung_tage_bis_faelligkeit); + const pruefungTooltip = inspTooltipTitle(item.pruefung_tage_bis_faelligkeit, item.naechste_pruefung_am); + + return ( + + {item.ist_wichtig && ( + + + + )} + + onClick(item.id)} + sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }} + > + + + + + + + + + {item.bezeichnung} + + + + + + + + {/* Location */} + + {item.fahrzeug_bezeichnung ? ( + + + {item.fahrzeug_bezeichnung} + {item.fahrzeug_kurzname && ` (${item.fahrzeug_kurzname})`} + + ) : ( + + {item.standort} + + )} + + + {/* Serial number */} + {item.seriennummer && ( + + SN: {item.seriennummer} + + )} + + {/* Status chip */} + + + + + {/* Inspection badge */} + {pruefungLabel && ( + + + + : undefined} + sx={{ fontSize: '0.7rem' }} + /> + + + )} + + + + ); +}; + +// ── Main Page ───────────────────────────────────────────────────────────────── function Ausruestung() { + const navigate = useNavigate(); + const { canManageEquipment } = usePermissions(); + + // Data state + const [equipment, setEquipment] = useState([]); + const [categories, setCategories] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Filter state + const [search, setSearch] = useState(''); + const [selectedCategory, setSelectedCategory] = useState(''); + const [selectedStatus, setSelectedStatus] = useState(''); + const [nurWichtige, setNurWichtige] = useState(false); + const [pruefungFaellig, setPruefungFaellig] = useState(false); + + const fetchData = useCallback(async () => { + try { + setLoading(true); + setError(null); + const [equipmentData, categoriesData, statsData] = await Promise.all([ + equipmentApi.getAll(), + equipmentApi.getCategories(), + equipmentApi.getStats(), + ]); + setEquipment(equipmentData); + setCategories(categoriesData); + setStats(statsData); + } catch { + setError('Ausrüstung konnte nicht geladen werden. Bitte versuchen Sie es erneut.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchData(); }, [fetchData]); + + // Client-side filtering + const filtered = useMemo(() => { + return equipment.filter((item) => { + // Text search + if (search.trim()) { + const q = search.toLowerCase(); + const matches = + item.bezeichnung.toLowerCase().includes(q) || + (item.seriennummer?.toLowerCase().includes(q) ?? false) || + (item.inventarnummer?.toLowerCase().includes(q) ?? false) || + (item.hersteller?.toLowerCase().includes(q) ?? false); + if (!matches) return false; + } + + // Category filter + if (selectedCategory && item.kategorie_id !== selectedCategory) { + return false; + } + + // Status filter + if (selectedStatus && item.status !== selectedStatus) { + return false; + } + + // Nur wichtige + if (nurWichtige && !item.ist_wichtig) { + return false; + } + + // Prüfung fällig (within 30 days or overdue) + if (pruefungFaellig) { + if (item.pruefung_tage_bis_faelligkeit === null || item.pruefung_tage_bis_faelligkeit > 30) { + return false; + } + } + + return true; + }); + }, [equipment, search, selectedCategory, selectedStatus, nurWichtige, pruefungFaellig]); + + const hasOverdue = equipment.some( + (item) => item.pruefung_tage_bis_faelligkeit !== null && item.pruefung_tage_bis_faelligkeit < 0 + ); + return ( - - Ausrüstungsverwaltung - - - - - - - - Ausrüstung + {/* Header */} + + + + Ausrüstungsverwaltung + + {!loading && stats && ( + - Diese Funktion wird in Kürze verfügbar sein + {stats.total} Gesamt + + {'·'} + + {stats.einsatzbereit} Einsatzbereit + + {'·'} + + {stats.beschaedigt} Beschädigt + + {'·'} + 0 ? 'warning.main' : 'text.secondary'} fontWeight={stats.inspectionsDue > 0 ? 600 : 400}> + {stats.inspectionsDue + stats.inspectionsOverdue} Prüfung fällig - - - - Geplante Features: - -
    -
  • - - Inventarverwaltung - -
  • -
  • - - Wartungsprüfungen und -protokolle - -
  • -
  • - - Prüffristen und Erinnerungen - -
  • -
  • - - Schutzausrüstung (PSA) - -
  • -
  • - - Atemschutzgeräte und -wartung - -
  • -
-
-
-
+ )} + + + + {/* Overdue alert */} + {hasOverdue && ( + }> + Achtung: Mindestens eine Ausrüstung hat eine überfällige Prüfungsfrist. + + )} + + {/* Filter controls */} + + setSearch(e.target.value)} + size="small" + sx={{ minWidth: 280, flexGrow: 1, maxWidth: 480 }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + Kategorie + + + + + Status + + + + setNurWichtige(e.target.checked)} + size="small" + /> + } + label={Nur wichtige} + /> + + setPruefungFaellig(e.target.checked)} + size="small" + /> + } + label={Prüfung fällig} + /> + + + {/* Loading state */} + {loading && ( + + + + )} + + {/* Error state */} + {!loading && error && ( + + Erneut versuchen + + } + > + {error} + + )} + + {/* Empty states */} + {!loading && !error && filtered.length === 0 && ( + + + + {equipment.length === 0 + ? 'Keine Ausrüstung vorhanden' + : 'Keine Ausrüstung gefunden'} + + + )} + + {/* Equipment grid */} + {!loading && !error && filtered.length > 0 && ( + + {filtered.map((item) => ( + + navigate(`/ausruestung/${id}`)} + /> + + ))} + + )} + + {/* FAB for adding new equipment */} + {canManageEquipment && ( + navigate('/ausruestung/neu')} + > + + + )}
); diff --git a/frontend/src/pages/AusruestungDetail.tsx b/frontend/src/pages/AusruestungDetail.tsx new file mode 100644 index 0000000..e25c362 --- /dev/null +++ b/frontend/src/pages/AusruestungDetail.tsx @@ -0,0 +1,762 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Container, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Divider, + Fab, + FormControl, + Grid, + IconButton, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + Tab, + Tabs, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { + Add, + ArrowBack, + Build, + CheckCircle, + DeleteOutline, + Edit, + Error as ErrorIcon, + MoreHoriz, + PauseCircle, + RemoveCircle, + Star, + Verified, + Warning, +} from '@mui/icons-material'; +import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { equipmentApi } from '../services/equipment'; +import { + AusruestungDetail, + AusruestungWartungslog, + AusruestungWartungslogArt, + AusruestungStatus, + AusruestungStatusLabel, + UpdateAusruestungStatusPayload, + CreateAusruestungWartungslogPayload, +} from '../types/equipment.types'; +import { usePermissions } from '../hooks/usePermissions'; +import { useNotification } from '../contexts/NotificationContext'; + +// -- Tab Panel ---------------------------------------------------------------- + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +const TabPanel: React.FC = ({ children, value, index }) => ( + +); + +// -- Status config ------------------------------------------------------------ + +const STATUS_ICONS: Record = { + [AusruestungStatus.Einsatzbereit]: , + [AusruestungStatus.Beschaedigt]: , + [AusruestungStatus.InWartung]: , + [AusruestungStatus.AusserDienst]: , +}; + +const STATUS_CHIP_COLOR: Record = { + [AusruestungStatus.Einsatzbereit]: 'success', + [AusruestungStatus.Beschaedigt]: 'error', + [AusruestungStatus.InWartung]: 'warning', + [AusruestungStatus.AusserDienst]: 'default', +}; + +// -- Date helpers ------------------------------------------------------------- + +function fmtDate(iso: string | null | undefined): string { + if (!iso) return '---'; + return new Date(iso).toLocaleDateString('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', + }); +} + +// -- Wartungslog Art config --------------------------------------------------- + +const WARTUNG_ART_CHIP_COLOR: Record = { + 'Prüfung': 'info', + 'Reparatur': 'warning', + 'Sonstiges': 'default', +}; + +const WARTUNG_ART_ICONS: Record = { + 'Prüfung': , + 'Reparatur': , + 'Sonstiges': , + default: , +}; + +const ERGEBNIS_CHIP_COLOR: Record = { + bestanden: 'success', + bestanden_mit_maengeln: 'warning', + nicht_bestanden: 'error', +}; + +const ERGEBNIS_LABEL: Record = { + bestanden: 'Bestanden', + bestanden_mit_maengeln: 'Bestanden (mit Mängeln)', + nicht_bestanden: 'Nicht bestanden', +}; + +// -- Uebersicht Tab ----------------------------------------------------------- + +interface UebersichtTabProps { + equipment: AusruestungDetail; + onStatusUpdated: () => void; + canChangeStatus: boolean; +} + +const UebersichtTab: React.FC = ({ equipment, onStatusUpdated, canChangeStatus }) => { + const [statusDialogOpen, setStatusDialogOpen] = useState(false); + const [newStatus, setNewStatus] = useState(equipment.status); + const [bemerkung, setBemerkung] = useState(equipment.status_bemerkung ?? ''); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const openDialog = () => { + setNewStatus(equipment.status); + setBemerkung(equipment.status_bemerkung ?? ''); + setSaveError(null); + setStatusDialogOpen(true); + }; + + const closeDialog = () => { + setSaveError(null); + setStatusDialogOpen(false); + }; + + const handleSaveStatus = async () => { + try { + setSaving(true); + setSaveError(null); + const payload: UpdateAusruestungStatusPayload = { status: newStatus, bemerkung }; + await equipmentApi.updateStatus(equipment.id, payload); + setStatusDialogOpen(false); + onStatusUpdated(); + } catch { + setSaveError('Status konnte nicht gespeichert werden.'); + } finally { + setSaving(false); + } + }; + + const isBeschaedigt = equipment.status === AusruestungStatus.Beschaedigt; + + // Inspection deadline + const pruefTage = equipment.pruefung_tage_bis_faelligkeit; + + // Data grid fields + const dataFields: { label: string; value: React.ReactNode }[] = [ + { label: 'Kategorie', value: equipment.kategorie_name }, + { label: 'Seriennummer', value: equipment.seriennummer ?? '---' }, + { label: 'Inventarnummer', value: equipment.inventarnummer ?? '---' }, + { label: 'Hersteller', value: equipment.hersteller ?? '---' }, + { label: 'Baujahr', value: equipment.baujahr ?? '---' }, + { + label: 'Fahrzeug', + value: equipment.fahrzeug_id ? ( + + {equipment.fahrzeug_bezeichnung} + + ) : '---', + }, + ...(!equipment.fahrzeug_id ? [{ label: 'Standort', value: equipment.standort || '---' }] : []), + { + label: 'Wichtig', + value: equipment.ist_wichtig ? ( + + Ja + + ) : 'Nein', + }, + { + label: 'Prüfintervall', + value: equipment.pruef_intervall_monate + ? `${equipment.pruef_intervall_monate} Monate` + : '---', + }, + { label: 'Letzte Prüfung', value: fmtDate(equipment.letzte_pruefung_am) }, + { + label: 'Nächste Prüfung', + value: equipment.naechste_pruefung_am ? ( + + {fmtDate(equipment.naechste_pruefung_am)} + {pruefTage !== null && pruefTage < 0 && ( + } + label={`${Math.abs(pruefTage)} Tage überfällig`} + sx={{ mt: 0.5 }} + /> + )} + {pruefTage !== null && pruefTage >= 0 && pruefTage <= 30 && ( + + )} + {pruefTage !== null && pruefTage > 30 && ( + + )} + + ) : ( + + ), + }, + { label: 'Bemerkung', value: equipment.bemerkung ?? '---' }, + ]; + + return ( + + {isBeschaedigt && ( + } sx={{ mb: 2 }}> + Beschädigt --- dieses Gerät ist nicht einsatzbereit. + {equipment.status_bemerkung && ` Bemerkung: ${equipment.status_bemerkung}`} + + )} + + {/* Status panel */} + + + + {STATUS_ICONS[equipment.status]} + + Aktueller Status + + {equipment.status_bemerkung && ( + + {equipment.status_bemerkung} + + )} + + + {canChangeStatus && ( + + )} + + + + {/* Equipment data grid */} + + {dataFields.map(({ label, value }) => ( + + + {label} + + {typeof value === 'string' || typeof value === 'number' ? ( + {value} + ) : ( + {value} + )} + + ))} + + + {/* Status change dialog */} + + Gerätestatus ändern + + {saveError && {saveError}} + + Neuer Status + + + setBemerkung(e.target.value)} + placeholder="z.B. Gerät zur Reparatur eingeschickt, voraussichtlich ab 01.03. wieder einsatzbereit" + /> + + + + + + + + ); +}; + +// -- Wartung Tab -------------------------------------------------------------- + +interface WartungTabProps { + equipmentId: string; + wartungslog: AusruestungWartungslog[]; + onAdded: () => void; + canWrite: boolean; +} + +const WartungTab: React.FC = ({ equipmentId, wartungslog, onAdded, canWrite }) => { + const [dialogOpen, setDialogOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const emptyForm: CreateAusruestungWartungslogPayload = { + datum: '', + art: 'Prüfung' as AusruestungWartungslogArt, + beschreibung: '', + ergebnis: undefined, + kosten: undefined, + pruefende_stelle: undefined, + }; + + const [form, setForm] = useState(emptyForm); + + const handleSubmit = async () => { + if (!form.datum || !form.art || !form.beschreibung.trim()) { + setSaveError('Datum, Art und Beschreibung sind erforderlich.'); + return; + } + try { + setSaving(true); + setSaveError(null); + await equipmentApi.addWartungslog(equipmentId, { + ...form, + pruefende_stelle: form.pruefende_stelle || undefined, + ergebnis: form.ergebnis || undefined, + }); + setDialogOpen(false); + setForm(emptyForm); + onAdded(); + } catch { + setSaveError('Wartungseintrag konnte nicht gespeichert werden.'); + } finally { + setSaving(false); + } + }; + + // Sort wartungslog by datum DESC + const sorted = [...wartungslog].sort( + (a, b) => new Date(b.datum).getTime() - new Date(a.datum).getTime() + ); + + return ( + + {sorted.length === 0 ? ( + Keine Wartungseinträge vorhanden. + ) : ( + } spacing={0}> + {sorted.map((entry) => { + const artIcon = WARTUNG_ART_ICONS[entry.art ?? ''] ?? WARTUNG_ART_ICONS.default; + const ergebnisColor = entry.ergebnis ? ERGEBNIS_CHIP_COLOR[entry.ergebnis] : undefined; + const ergebnisLabel = entry.ergebnis ? ERGEBNIS_LABEL[entry.ergebnis] : undefined; + return ( + + {artIcon} + + + {fmtDate(entry.datum)} + {entry.art && ( + + )} + {ergebnisLabel && ergebnisColor && ( + + )} + + {entry.beschreibung} + + {[ + entry.kosten != null && `${entry.kosten.toFixed(2)} EUR`, + entry.pruefende_stelle && entry.pruefende_stelle, + ].filter(Boolean).join(' · ')} + + + + ); + })} + + )} + + {canWrite && ( + { setForm(emptyForm); setSaveError(null); setDialogOpen(true); }} + > + + + )} + + setDialogOpen(false)} maxWidth="sm" fullWidth> + Wartung / Prüfung eintragen + + {saveError && {saveError}} + + + setForm((f) => ({ ...f, datum: e.target.value }))} + InputLabelProps={{ shrink: true }} + /> + + + + Art * + + + + + setForm((f) => ({ ...f, beschreibung: e.target.value }))} + /> + + + + Ergebnis + + + + + + setForm((f) => ({ + ...f, + kosten: e.target.value ? Number(e.target.value) : undefined, + })) + } + inputProps={{ min: 0, step: 0.01 }} + /> + + + setForm((f) => ({ ...f, pruefende_stelle: e.target.value }))} + placeholder="Name der prüfenden Stelle oder Person" + /> + + + + + + + + + + ); +}; + +// -- Main Page ---------------------------------------------------------------- + +function AusruestungDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { isAdmin, canChangeStatus } = usePermissions(); + const notification = useNotification(); + + const [equipment, setEquipment] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState(0); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteLoading, setDeleteLoading] = useState(false); + + const fetchEquipment = useCallback(async () => { + if (!id) return; + try { + setLoading(true); + setError(null); + const data = await equipmentApi.getById(id); + setEquipment(data); + } catch { + setError('Gerät konnte nicht geladen werden.'); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { fetchEquipment(); }, [fetchEquipment]); + + const handleDelete = async () => { + if (!id) return; + try { + setDeleteLoading(true); + await equipmentApi.delete(id); + notification.showSuccess('Gerät wurde erfolgreich gelöscht.'); + navigate('/ausruestung'); + } catch { + notification.showError('Gerät konnte nicht gelöscht werden.'); + setDeleteDialogOpen(false); + setDeleteLoading(false); + } + }; + + if (loading) { + return ( + + + + + + ); + } + + if (error || !equipment) { + return ( + + + {error ?? 'Gerät nicht gefunden.'} + + + + ); + } + + const hasOverdue = + equipment.pruefung_tage_bis_faelligkeit !== null && + equipment.pruefung_tage_bis_faelligkeit < 0; + + const subtitle = [ + equipment.kategorie_name, + equipment.seriennummer ? `SN: ${equipment.seriennummer}` : null, + ].filter(Boolean).join(' · '); + + return ( + + + + + + + + + {equipment.bezeichnung} + + {subtitle && ( + + {subtitle} + + )} + + + + {canChangeStatus && ( + + navigate(`/ausruestung/${equipment.id}/bearbeiten`)} + aria-label="Gerät bearbeiten" + > + + + + )} + {isAdmin && ( + + setDeleteDialogOpen(true)} + aria-label="Gerät löschen" + > + + + + )} + + + + + setActiveTab(v)} + aria-label="Ausrüstung Detailansicht" + > + + + Wartung + + : 'Wartung' + } + /> + + + + + + + + + + + + {/* Delete confirmation dialog */} + !deleteLoading && setDeleteDialogOpen(false)}> + Gerät löschen + + + Möchten Sie '{equipment.bezeichnung}' wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden. + + + + + + + + + + ); +} + +export default AusruestungDetailPage; diff --git a/frontend/src/pages/AusruestungForm.tsx b/frontend/src/pages/AusruestungForm.tsx new file mode 100644 index 0000000..6f3694f --- /dev/null +++ b/frontend/src/pages/AusruestungForm.tsx @@ -0,0 +1,521 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { + Alert, + Box, + Button, + CircularProgress, + Container, + FormControl, + FormControlLabel, + Grid, + InputLabel, + MenuItem, + Paper, + Select, + Switch, + 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 { equipmentApi } from '../services/equipment'; +import { vehiclesApi } from '../services/vehicles'; +import { + AusruestungStatus, + AusruestungStatusLabel, + CreateAusruestungPayload, + UpdateAusruestungPayload, + AusruestungKategorie, +} from '../types/equipment.types'; +import type { FahrzeugListItem } from '../types/vehicle.types'; +import { usePermissions } from '../hooks/usePermissions'; + +// -- Form state shape --------------------------------------------------------- + +interface FormState { + bezeichnung: string; + kategorie_id: string; + seriennummer: string; + inventarnummer: string; + hersteller: string; + baujahr: string; // stored as string for input, converted to number on submit + status: AusruestungStatus; + status_bemerkung: string; + ist_wichtig: boolean; + fahrzeug_id: string; + standort: string; + pruef_intervall_monate: string; + letzte_pruefung_am: string; + naechste_pruefung_am: string; + bemerkung: string; +} + +const EMPTY_FORM: FormState = { + bezeichnung: '', + kategorie_id: '', + seriennummer: '', + inventarnummer: '', + hersteller: '', + baujahr: '', + status: AusruestungStatus.Einsatzbereit, + status_bemerkung: '', + ist_wichtig: false, + fahrzeug_id: '', + standort: 'Lager', + pruef_intervall_monate: '', + letzte_pruefung_am: '', + naechste_pruefung_am: '', + bemerkung: '', +}; + +// -- 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 AusruestungForm() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { canChangeStatus } = usePermissions(); + const isEditMode = Boolean(id); + + // -- Permission guard: only authorized users may create or edit equipment ---- + if (!canChangeStatus) { + return ( + + + + + Keine Berechtigung + + + Sie haben nicht die erforderlichen Rechte, um Ausrüstung zu bearbeiten. + + + + + + ); + } + + 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>>({}); + + // -- Lookup data ------------------------------------------------------------ + const [categories, setCategories] = useState([]); + const [vehicles, setVehicles] = useState([]); + + const fetchLookups = useCallback(async () => { + try { + const [cats, vehs] = await Promise.all([ + equipmentApi.getCategories(), + vehiclesApi.getAll(), + ]); + setCategories(cats); + setVehicles(vehs); + } catch { + // Non-critical: dropdowns will be empty but form still usable + } + }, []); + + const fetchEquipment = useCallback(async () => { + if (!id) return; + try { + setLoading(true); + setError(null); + const equipment = await equipmentApi.getById(id); + setForm({ + bezeichnung: equipment.bezeichnung, + kategorie_id: equipment.kategorie_id, + seriennummer: equipment.seriennummer ?? '', + inventarnummer: equipment.inventarnummer ?? '', + hersteller: equipment.hersteller ?? '', + baujahr: equipment.baujahr?.toString() ?? '', + status: equipment.status, + status_bemerkung: equipment.status_bemerkung ?? '', + ist_wichtig: equipment.ist_wichtig, + fahrzeug_id: equipment.fahrzeug_id ?? '', + standort: equipment.standort ?? 'Lager', + pruef_intervall_monate: equipment.pruef_intervall_monate?.toString() ?? '', + letzte_pruefung_am: toDateInput(equipment.letzte_pruefung_am), + naechste_pruefung_am: toDateInput(equipment.naechste_pruefung_am), + bemerkung: equipment.bemerkung ?? '', + }); + } catch { + setError('Ausrüstung konnte nicht geladen werden.'); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { + fetchLookups(); + }, [fetchLookups]); + + useEffect(() => { + if (isEditMode) fetchEquipment(); + }, [isEditMode, fetchEquipment]); + + // -- Validation ------------------------------------------------------------- + + const validate = (): boolean => { + const errors: Partial> = {}; + if (!form.bezeichnung.trim()) { + errors.bezeichnung = 'Bezeichnung ist erforderlich.'; + } + if (!form.kategorie_id) { + errors.kategorie_id = 'Kategorie ist erforderlich.'; + } + if (form.baujahr) { + const year = parseInt(form.baujahr, 10); + if (isNaN(year) || year < 1950 || year > 2100) { + errors.baujahr = 'Baujahr muss zwischen 1950 und 2100 liegen.'; + } + } + if (form.pruef_intervall_monate) { + const months = parseInt(form.pruef_intervall_monate, 10); + if (isNaN(months) || months < 1 || months > 120) { + errors.pruef_intervall_monate = 'Prüfintervall muss zwischen 1 und 120 Monaten liegen.'; + } + } + setFieldErrors(errors); + return Object.keys(errors).length === 0; + }; + + // -- Submit ----------------------------------------------------------------- + + const handleSubmit = async () => { + if (!validate()) return; + + try { + setSaving(true); + setSaveError(null); + + if (isEditMode && id) { + const payload: UpdateAusruestungPayload = { + bezeichnung: form.bezeichnung.trim() || undefined, + kategorie_id: form.kategorie_id || undefined, + seriennummer: form.seriennummer.trim() || undefined, + inventarnummer: form.inventarnummer.trim() || undefined, + hersteller: form.hersteller.trim() || undefined, + baujahr: form.baujahr ? parseInt(form.baujahr, 10) : undefined, + status: form.status, + status_bemerkung: form.status_bemerkung.trim() || undefined, + ist_wichtig: form.ist_wichtig, + fahrzeug_id: form.fahrzeug_id || null, + standort: !form.fahrzeug_id ? (form.standort.trim() || 'Lager') : undefined, + pruef_intervall_monate: form.pruef_intervall_monate ? parseInt(form.pruef_intervall_monate, 10) : undefined, + letzte_pruefung_am: form.letzte_pruefung_am || undefined, + naechste_pruefung_am: form.naechste_pruefung_am || undefined, + bemerkung: form.bemerkung.trim() || undefined, + }; + await equipmentApi.update(id, payload); + navigate(`/ausruestung/${id}`); + } else { + const payload: CreateAusruestungPayload = { + bezeichnung: form.bezeichnung.trim(), + kategorie_id: form.kategorie_id, + seriennummer: form.seriennummer.trim() || undefined, + inventarnummer: form.inventarnummer.trim() || undefined, + hersteller: form.hersteller.trim() || undefined, + baujahr: form.baujahr ? parseInt(form.baujahr, 10) : undefined, + status: form.status, + status_bemerkung: form.status_bemerkung.trim() || undefined, + ist_wichtig: form.ist_wichtig, + fahrzeug_id: form.fahrzeug_id || undefined, + standort: !form.fahrzeug_id ? (form.standort.trim() || 'Lager') : undefined, + pruef_intervall_monate: form.pruef_intervall_monate ? parseInt(form.pruef_intervall_monate, 10) : undefined, + letzte_pruefung_am: form.letzte_pruefung_am || undefined, + naechste_pruefung_am: form.naechste_pruefung_am || undefined, + bemerkung: form.bemerkung.trim() || undefined, + }; + const created = await equipmentApi.create(payload); + navigate(`/ausruestung/${created.id}`); + } + } catch { + setSaveError( + isEditMode + ? 'Ausrüstung konnte nicht gespeichert werden.' + : 'Ausrüstung konnte nicht erstellt werden.' + ); + } finally { + setSaving(false); + } + }; + + // -- Field helper ----------------------------------------------------------- + + 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], + }); + + // -- Loading / Error early returns ------------------------------------------ + + if (loading) { + return ( + + + + + + ); + } + + if (error) { + return ( + + + {error} + + + + ); + } + + // -- Render ----------------------------------------------------------------- + + return ( + + + + + + {isEditMode ? 'Gerät bearbeiten' : 'Neues Gerät anlegen'} + + + {saveError && {saveError}} + + + {/* ── Section: Grunddaten ──────────────────────────────────────────── */} + Grunddaten + + + + + + + Kategorie * + + {fieldErrors.kategorie_id && ( + + {fieldErrors.kategorie_id} + + )} + + + + + + + + + + + + + + + + + {/* ── Section: Status & Zuordnung ──────────────────────────────────── */} + Status & Zuordnung + + + + Status + + + + {form.status !== AusruestungStatus.Einsatzbereit && ( + + + + )} + + setForm((prev) => ({ ...prev, ist_wichtig: e.target.checked }))} + /> + } + label="Wichtiges Gerät (Warnung auf Fahrzeugkarte wenn nicht einsatzbereit)" + /> + + + + Fahrzeug + + + + {!form.fahrzeug_id && ( + + + + )} + + + {/* ── Section: Pruefung & Wartung ───────────────────────────────────── */} + Prüfung & Wartung + + + + + + setForm((prev) => ({ ...prev, letzte_pruefung_am: e.target.value }))} + InputLabelProps={{ shrink: true }} + /> + + + setForm((prev) => ({ ...prev, naechste_pruefung_am: e.target.value }))} + InputLabelProps={{ shrink: true }} + /> + + + + {/* ── Section: Bemerkungen ──────────────────────────────────────────── */} + Bemerkungen + + + + + + + + + + + + + + ); +} + +export default AusruestungForm; diff --git a/frontend/src/pages/FahrzeugDetail.tsx b/frontend/src/pages/FahrzeugDetail.tsx index 93ee8cc..fe5f9b9 100644 --- a/frontend/src/pages/FahrzeugDetail.tsx +++ b/frontend/src/pages/FahrzeugDetail.tsx @@ -9,6 +9,7 @@ import { Dialog, DialogActions, DialogContent, + DialogContentText, DialogTitle, Divider, Fab, @@ -16,11 +17,18 @@ import { Grid, IconButton, InputLabel, + Link, MenuItem, Paper, Select, Stack, Tab, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, Tabs, TextField, Tooltip, @@ -32,6 +40,7 @@ import { Assignment, Build, CheckCircle, + DeleteOutline, DirectionsCar, Edit, Error as ErrorIcon, @@ -40,12 +49,14 @@ import { PauseCircle, ReportProblem, School, + Star, Verified, Warning, } from '@mui/icons-material'; import { useNavigate, useParams } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import { vehiclesApi } from '../services/vehicles'; +import { equipmentApi } from '../services/equipment'; import { FahrzeugDetail, FahrzeugWartungslog, @@ -55,7 +66,10 @@ import { UpdateStatusPayload, WartungslogArt, } from '../types/vehicle.types'; +import type { AusruestungListItem } from '../types/equipment.types'; +import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types'; import { usePermissions } from '../hooks/usePermissions'; +import { useNotification } from '../contexts/NotificationContext'; // ── Tab Panel ───────────────────────────────────────────────────────────────── @@ -195,12 +209,6 @@ const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, { label: 'Bezeichnung', value: vehicle.bezeichnung }, { label: 'Kurzname', value: vehicle.kurzname }, { label: 'Kennzeichen', value: vehicle.amtliches_kennzeichen }, - { label: 'Fahrgestellnr.', value: vehicle.fahrgestellnummer }, - { label: 'Baujahr', value: vehicle.baujahr?.toString() }, - { label: 'Hersteller', value: vehicle.hersteller }, - { label: 'Typ (DIN 14502)', value: vehicle.typ_schluessel }, - { label: 'Besatzung (Soll)', value: vehicle.besatzung_soll }, - { label: 'Standort', value: vehicle.standort }, ].map(({ label, value }) => ( @@ -492,17 +500,153 @@ const WartungTab: React.FC = ({ fahrzeugId, wartungslog, onAdde ); }; +// ── Ausrüstung Tab ─────────────────────────────────────────────────────────── + +const EQUIPMENT_STATUS_COLOR: Record = { + [AusruestungStatus.Einsatzbereit]: 'success', + [AusruestungStatus.Beschaedigt]: 'error', + [AusruestungStatus.InWartung]: 'warning', + [AusruestungStatus.AusserDienst]: 'default', +}; + +function pruefungBadgeColor(tage: number | null): 'success' | 'warning' | 'error' | 'default' { + if (tage === null) return 'default'; + if (tage < 0) return 'error'; + if (tage <= 30) return 'warning'; + return 'success'; +} + +interface AusruestungTabProps { + equipment: AusruestungListItem[]; + vehicleId: string; +} + +const AusruestungTab: React.FC = ({ equipment, vehicleId }) => { + const navigate = useNavigate(); + + const hasProblems = equipment.some( + (e) => e.status === AusruestungStatus.Beschaedigt || e.status === AusruestungStatus.InWartung + ); + + if (equipment.length === 0) { + return ( + + + + Keine Ausrüstung zugewiesen + + + Diesem Fahrzeug ist derzeit keine Ausrüstung zugeordnet. + + + + ); + } + + return ( + + {hasProblems && ( + } sx={{ mb: 2 }}> + Achtung: Eine oder mehrere Ausrüstungen dieses Fahrzeugs sind beschädigt oder in Wartung. + + )} + + + + + + Bezeichnung + Kategorie + Status + Wichtig + Nächste Prüfung + + + + {equipment.map((item) => { + const statusColor = EQUIPMENT_STATUS_COLOR[item.status] ?? 'default'; + const pruefTage = item.pruefung_tage_bis_faelligkeit; + const pruefColor = pruefungBadgeColor(pruefTage); + + return ( + + + navigate(`/ausruestung/${item.id}`)} + sx={{ textAlign: 'left' }} + > + {item.bezeichnung} + + + + + + + + + + {item.ist_wichtig && ( + + + + )} + + + {item.naechste_pruefung_am ? ( + : undefined} + /> + ) : ( + + )} + + + ); + })} + +
+
+
+ ); +}; + // ── Main Page ───────────────────────────────────────────────────────────────── function FahrzeugDetail() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { isAdmin, canChangeStatus } = usePermissions(); + const notification = useNotification(); const [vehicle, setVehicle] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [activeTab, setActiveTab] = useState(0); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteLoading, setDeleteLoading] = useState(false); + const [vehicleEquipment, setVehicleEquipment] = useState([]); const fetchVehicle = useCallback(async () => { if (!id) return; @@ -511,6 +655,13 @@ function FahrzeugDetail() { setError(null); const data = await vehiclesApi.getById(id); setVehicle(data); + // Fetch equipment separately — failure must not break the page + try { + const eq = await equipmentApi.getByVehicle(id); + setVehicleEquipment(eq); + } catch { + setVehicleEquipment([]); + } } catch { setError('Fahrzeug konnte nicht geladen werden.'); } finally { @@ -520,6 +671,20 @@ function FahrzeugDetail() { useEffect(() => { fetchVehicle(); }, [fetchVehicle]); + const handleDeleteVehicle = async () => { + if (!id) return; + try { + setDeleteLoading(true); + await vehiclesApi.delete(id); + notification.showSuccess('Fahrzeug wurde erfolgreich gelöscht.'); + navigate('/fahrzeuge'); + } catch { + notification.showError('Fahrzeug konnte nicht gelöscht werden.'); + setDeleteDialogOpen(false); + setDeleteLoading(false); + } + }; + if (loading) { return ( @@ -573,7 +738,6 @@ function FahrzeugDetail() { {vehicle.amtliches_kennzeichen && ( {vehicle.amtliches_kennzeichen} - {vehicle.hersteller && ` · ${vehicle.hersteller}`} )} @@ -594,6 +758,18 @@ function FahrzeugDetail() { )} + {isAdmin && ( + + setDeleteDialogOpen(true)} + aria-label="Fahrzeug löschen" + > + + + + )} @@ -614,6 +790,7 @@ function FahrzeugDetail() { } /> + 0 ? ` (${vehicleEquipment.length})` : ''}`} /> @@ -645,6 +822,38 @@ function FahrzeugDetail() {
+ + + + + + {/* Delete confirmation dialog */} + !deleteLoading && setDeleteDialogOpen(false)}> + Fahrzeug löschen + + + Möchten Sie das Fahrzeug '{vehicle.bezeichnung}' wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden. + + + + + + + ); diff --git a/frontend/src/pages/FahrzeugForm.tsx b/frontend/src/pages/FahrzeugForm.tsx index 877a382..d2827da 100644 --- a/frontend/src/pages/FahrzeugForm.tsx +++ b/frontend/src/pages/FahrzeugForm.tsx @@ -24,6 +24,7 @@ import { CreateFahrzeugPayload, UpdateFahrzeugPayload, } from '../types/vehicle.types'; +import { usePermissions } from '../hooks/usePermissions'; // ── Form state shape ────────────────────────────────────────────────────────── @@ -74,8 +75,30 @@ function toDateInput(iso: string | null | undefined): string { function FahrzeugForm() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const { isAdmin } = usePermissions(); const isEditMode = Boolean(id); + // ── Permission guard: only admins may create or edit vehicles ────────────── + if (!isAdmin) { + return ( + + + + + Keine Berechtigung + + + Sie haben nicht die erforderlichen Rechte, um Fahrzeuge zu bearbeiten. + + + + + + ); + } + const [form, setForm] = useState(EMPTY_FORM); const [loading, setLoading] = useState(isEditMode); const [saving, setSaving] = useState(false); @@ -121,9 +144,6 @@ function FahrzeugForm() { 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; }; @@ -261,53 +281,6 @@ function FahrzeugForm() { placeholder="z.B. WN-FW 1" />
- - - - - - - - - - - - - - - - - - Status diff --git a/frontend/src/pages/Fahrzeuge.tsx b/frontend/src/pages/Fahrzeuge.tsx index bd78795..5f436d2 100644 --- a/frontend/src/pages/Fahrzeuge.tsx +++ b/frontend/src/pages/Fahrzeuge.tsx @@ -31,6 +31,9 @@ import { import { useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import { vehiclesApi } from '../services/vehicles'; +import { equipmentApi } from '../services/equipment'; +import type { VehicleEquipmentWarning } from '../types/equipment.types'; +import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types'; import { FahrzeugListItem, FahrzeugStatus, @@ -86,9 +89,10 @@ function inspTooltipTitle(fullLabel: string, tage: number | null, faelligAm: str interface VehicleCardProps { vehicle: FahrzeugListItem; onClick: (id: string) => void; + warnings?: VehicleEquipmentWarning[]; } -const VehicleCard: React.FC = ({ vehicle, onClick }) => { +const VehicleCard: React.FC = ({ vehicle, onClick, warnings = [] }) => { const status = vehicle.status as FahrzeugStatus; const statusCfg = STATUS_CONFIG[status] ?? STATUS_CONFIG[FahrzeugStatus.Einsatzbereit]; const isSchaden = status === FahrzeugStatus.AusserDienstSchaden; @@ -183,13 +187,6 @@ const VehicleCard: React.FC = ({ vehicle, onClick }) => { /> - {vehicle.besatzung_soll && ( - - Besatzung: {vehicle.besatzung_soll} - {vehicle.baujahr && ` · Bj. ${vehicle.baujahr}`} - - )} - {inspBadges.length > 0 && ( {inspBadges.map((b) => { @@ -214,6 +211,18 @@ const VehicleCard: React.FC = ({ vehicle, onClick }) => { })} )} + + {warnings.length > 0 && ( + `${w.bezeichnung}: ${AusruestungStatusLabel[w.status]}`).join('\n')}> + } + label={`${warnings.length} Ausrüstung nicht bereit`} + color={warnings.some(w => w.status === AusruestungStatus.Beschaedigt) ? 'error' : 'warning'} + sx={{ mt: 0.5 }} + /> + + )} @@ -229,6 +238,7 @@ function Fahrzeuge() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [search, setSearch] = useState(''); + const [equipmentWarnings, setEquipmentWarnings] = useState>(new Map()); const fetchVehicles = useCallback(async () => { try { @@ -245,6 +255,26 @@ function Fahrzeuge() { useEffect(() => { fetchVehicles(); }, [fetchVehicles]); + // Fetch equipment warnings separately — must not block or delay vehicle list rendering + useEffect(() => { + async function fetchWarnings() { + try { + const warnings = await equipmentApi.getVehicleWarnings(); + const warningsMap = new Map(); + warnings.forEach(w => { + const existing = warningsMap.get(w.fahrzeug_id) || []; + existing.push(w); + warningsMap.set(w.fahrzeug_id, existing); + }); + setEquipmentWarnings(warningsMap); + } catch { + // Silently fail — equipment warnings are non-critical + setEquipmentWarnings(new Map()); + } + } + fetchWarnings(); + }, []); + const filtered = vehicles.filter((v) => { if (!search.trim()) return true; const q = search.toLowerCase(); @@ -337,6 +367,7 @@ function Fahrzeuge() { navigate(`/fahrzeuge/${id}`)} + warnings={equipmentWarnings.get(vehicle.id) || []} /> ))} diff --git a/frontend/src/services/equipment.ts b/frontend/src/services/equipment.ts new file mode 100644 index 0000000..b885969 --- /dev/null +++ b/frontend/src/services/equipment.ts @@ -0,0 +1,111 @@ +import { api } from './api'; +import type { + AusruestungListItem, + AusruestungDetail, + AusruestungWartungslog, + AusruestungKategorie, + EquipmentStats, + VehicleEquipmentWarning, + CreateAusruestungPayload, + UpdateAusruestungPayload, + UpdateAusruestungStatusPayload, + CreateAusruestungWartungslogPayload, +} from '../types/equipment.types'; + +async function unwrap( + promise: ReturnType> +): Promise { + const response = await promise; + if (response.data?.data === undefined || response.data?.data === null) { + throw new Error('Invalid API response'); + } + return response.data.data; +} + +export const equipmentApi = { + async getAll(): Promise { + return unwrap(api.get<{ success: boolean; data: AusruestungListItem[] }>('/api/equipment')); + }, + + async getById(id: string): Promise { + return unwrap(api.get<{ success: boolean; data: AusruestungDetail }>(`/api/equipment/${id}`)); + }, + + async getByVehicle(fahrzeugId: string): Promise { + return unwrap( + api.get<{ success: boolean; data: AusruestungListItem[] }>( + `/api/equipment/vehicle/${fahrzeugId}` + ) + ); + }, + + async getCategories(): Promise { + return unwrap( + api.get<{ success: boolean; data: AusruestungKategorie[] }>('/api/equipment/categories') + ); + }, + + async getStats(): Promise { + return unwrap(api.get<{ success: boolean; data: EquipmentStats }>('/api/equipment/stats')); + }, + + async getAlerts(daysAhead = 30): Promise { + return unwrap( + api.get<{ success: boolean; data: AusruestungListItem[] }>( + `/api/equipment/alerts?daysAhead=${daysAhead}` + ) + ); + }, + + async getVehicleWarnings(): Promise { + return unwrap( + api.get<{ success: boolean; data: VehicleEquipmentWarning[] }>( + '/api/equipment/vehicle-warnings' + ) + ); + }, + + async create(payload: CreateAusruestungPayload): Promise { + const response = await api.post<{ success: boolean; data: AusruestungDetail }>( + '/api/equipment', + payload + ); + if (response.data?.data === undefined || response.data?.data === null) { + throw new Error('Invalid API response'); + } + return response.data.data; + }, + + async update(id: string, payload: UpdateAusruestungPayload): Promise { + const response = await api.patch<{ success: boolean; data: AusruestungDetail }>( + `/api/equipment/${id}`, + payload + ); + if (response.data?.data === undefined || response.data?.data === null) { + throw new Error('Invalid API response'); + } + return response.data.data; + }, + + async delete(id: string): Promise { + await api.delete(`/api/equipment/${id}`); + }, + + async updateStatus(id: string, payload: UpdateAusruestungStatusPayload): Promise { + await api.patch(`/api/equipment/${id}/status`, payload); + }, + + async addWartungslog( + id: string, + payload: CreateAusruestungWartungslogPayload + ): Promise { + const response = await api.post<{ success: boolean; data: AusruestungWartungslog }>( + `/api/equipment/${id}/wartung`, + payload + ); + if (response.data?.data === undefined || response.data?.data === null) { + throw new Error('Invalid API response'); + } + return response.data.data; + }, +}; diff --git a/frontend/src/types/equipment.types.ts b/frontend/src/types/equipment.types.ts new file mode 100644 index 0000000..f603569 --- /dev/null +++ b/frontend/src/types/equipment.types.ts @@ -0,0 +1,136 @@ +// ============================================================================= +// Equipment Management — Frontend Type Definitions +// ============================================================================= + +export enum AusruestungStatus { + Einsatzbereit = 'einsatzbereit', + Beschaedigt = 'beschaedigt', + InWartung = 'in_wartung', + AusserDienst = 'ausser_dienst', +} + +export const AusruestungStatusLabel: Record = { + [AusruestungStatus.Einsatzbereit]: 'Einsatzbereit', + [AusruestungStatus.Beschaedigt]: 'Beschädigt', + [AusruestungStatus.InWartung]: 'In Wartung', + [AusruestungStatus.AusserDienst]: 'Außer Dienst', +}; + +export type AusruestungWartungslogArt = 'Prüfung' | 'Reparatur' | 'Sonstiges'; + +// ── Lookup Entity ──────────────────────────────────────────────────────────── + +export interface AusruestungKategorie { + id: string; + name: string; + kurzname: string; + sortierung: number; +} + +// ── API Response Shapes ────────────────────────────────────────────────────── + +export interface AusruestungListItem { + id: string; + bezeichnung: string; + kategorie_id: string; + kategorie_name: string; + kategorie_kurzname: string; + seriennummer: string | null; + inventarnummer: string | null; + hersteller: string | null; + baujahr: number | null; + status: AusruestungStatus; + status_bemerkung: string | null; + ist_wichtig: boolean; + fahrzeug_id: string | null; + fahrzeug_bezeichnung: string | null; + fahrzeug_kurzname: string | null; + standort: string; + naechste_pruefung_am: string | null; + pruefung_tage_bis_faelligkeit: number | null; + created_at: string; + updated_at: string; +} + +export interface AusruestungDetail extends AusruestungListItem { + pruef_intervall_monate: number | null; + letzte_pruefung_am: string | null; + bemerkung: string | null; + wartungslog: AusruestungWartungslog[]; +} + +export interface AusruestungWartungslog { + id: string; + ausruestung_id: string; + datum: string; + art: AusruestungWartungslogArt; + beschreibung: string; + ergebnis: string | null; + kosten: number | null; + pruefende_stelle: string | null; + dokument_url: string | null; + erfasst_von: string | null; + created_at: string; +} + +// ── Dashboard KPI ──────────────────────────────────────────────────────────── + +export interface EquipmentStats { + total: number; + einsatzbereit: number; + beschaedigt: number; + inWartung: number; + ausserDienst: number; + inspectionsDue: number; + inspectionsOverdue: number; + wichtigNichtBereit: number; +} + +// ── Vehicle Equipment Warning ──────────────────────────────────────────────── + +export interface VehicleEquipmentWarning { + fahrzeug_id: string; + ausruestung_id: string; + bezeichnung: string; + status: AusruestungStatus; + kategorie_name: string; +} + +// ── Request Payload Types ──────────────────────────────────────────────────── + +export interface CreateAusruestungPayload { + bezeichnung: string; + kategorie_id: string; + seriennummer?: string; + inventarnummer?: string; + hersteller?: string; + baujahr?: number; + status?: AusruestungStatus; + status_bemerkung?: string; + ist_wichtig?: boolean; + fahrzeug_id?: string; + standort?: string; + pruef_intervall_monate?: number; + letzte_pruefung_am?: string; + naechste_pruefung_am?: string; + bemerkung?: string; +} + +export type UpdateAusruestungPayload = { + [K in keyof CreateAusruestungPayload]?: CreateAusruestungPayload[K] | null; +}; + +export interface UpdateAusruestungStatusPayload { + status: AusruestungStatus; + bemerkung?: string; +} + +export interface CreateAusruestungWartungslogPayload { + datum: string; + art: AusruestungWartungslogArt; + beschreibung: string; + ergebnis?: string; + kosten?: number; + pruefende_stelle?: string; + dokument_url?: string; +} diff --git a/plans/2026-02-28-vehicle-equipment-features.md b/plans/2026-02-28-vehicle-equipment-features.md new file mode 100644 index 0000000..15bc743 --- /dev/null +++ b/plans/2026-02-28-vehicle-equipment-features.md @@ -0,0 +1,688 @@ +# Feuerwehr Dashboard — Vehicle & Equipment Feature Plan + +**Date:** 2026-02-28 +**Author:** Claude (brainstorm + analysis) + +--- + +## Feature Status Summary + +| # | Feature | Status | Work Needed | +|---|---------|--------|-------------| +| 1 | Add vehicle | DONE | None | +| 2 | Edit vehicle | DONE | None | +| 3 | Remove vehicle | BACKEND ONLY | Frontend UI (delete button + confirmation dialog) | +| 4 | Permission visibility | PARTIAL | Hide restricted UI elements + frontend route guards | +| 5 | Remove info fields | NOT DONE | Remove 6 fields from form, detail, list view | +| 6 | Equipment system | NOT IMPLEMENTED | Full-stack (DB, backend, frontend) | +| 7 | Equipment on vehicle detail | NOT IMPLEMENTED | New tab on vehicle detail page | +| 8 | Equipment warnings on vehicle cards | NOT IMPLEMENTED | Warning badges on vehicle overview | + +--- + +## Priority Order + +### Phase 1 — Quick Wins (vehicle cleanup) +1. **P1: Remove info fields** — Remove Fahrgestellnr., Standort, Besatzung, Typ, Hersteller, Baujahr from frontend +2. **P2: Delete vehicle UI** — Add delete button + confirmation dialog to FahrzeugDetail.tsx +3. **P3: Permission guards** — Hide restricted features from unauthorized user groups + +### Phase 2 — Equipment Backend +4. **P4: Database migration** — Create equipment tables (ausruestung, ausruestung_kategorien, ausruestung_wartungslog) +5. **P5: Backend model + service + controller + routes** — Full backend CRUD for equipment + +### Phase 3 — Equipment Frontend +6. **P6: Equipment list page** — Replace placeholder with full equipment management page +7. **P7: Equipment create/edit form** — AusruestungForm.tsx +8. **P8: Equipment detail page** — AusruestungDetail.tsx with tabs + +### Phase 4 — Vehicle-Equipment Integration +9. **P9: Vehicle detail equipment tab** — Show assigned equipment in vehicle detail +10. **P10: Vehicle card warning badges** — Show warnings when important equipment is not ready + +--- + +## Subagent Prompts + +### PROMPT 1: Remove Info Fields (P1) + +``` +You are a senior React/TypeScript developer working on a Feuerwehr (fire department) Dashboard. + +TASK: Remove these 6 info fields from the frontend display: +- Fahrgestellnr. (fahrgestellnummer) +- Standort (standort) +- Besatzung (besatzung_soll) +- Typ (typ_schluessel) +- Hersteller (hersteller) +- Baujahr (baujahr) + +FILES TO MODIFY: + +1. /Users/matthias/work/feuerwehr_dashboard/frontend/src/pages/FahrzeugForm.tsx + - Remove the TextField inputs for all 6 fields from the form + - Remove them from the form state (formData) and any related onChange handlers + - Keep the fields in the TypeScript types and backend — only remove from UI + +2. /Users/matthias/work/feuerwehr_dashboard/frontend/src/pages/FahrzeugDetail.tsx + - Remove the display rows for these 6 fields from the "Übersicht" tab data grid + - They appear in the info section showing vehicle details + +3. /Users/matthias/work/feuerwehr_dashboard/frontend/src/pages/Fahrzeuge.tsx + - Remove besatzung_soll and baujahr from the VehicleCard component display + - Remove hersteller from the search filter if it's included + - Keep the TypeScript types intact — only remove visual display + +IMPORTANT: +- Do NOT modify backend code, database, or TypeScript type definitions +- Do NOT remove the fields from API payloads — just stop displaying them +- Clean up any unused imports after removing the fields +- Verify the layout still looks good after removal (no empty gaps) +- Read each file first before making changes +``` + +### PROMPT 2: Delete Vehicle UI (P2) + +``` +You are a senior React/TypeScript developer working on a Feuerwehr Dashboard. + +TASK: Add a delete button with confirmation dialog to the vehicle detail page. + +CONTEXT: +- Backend soft-delete endpoint already exists: DELETE /api/vehicles/:id (admin only) +- Frontend API method exists: vehiclesApi.delete(id) in /frontend/src/services/vehicles.ts +- Permission hook: usePermissions() returns { isAdmin, canChangeStatus } +- Delete should only be visible to admin users (isAdmin === true) + +FILE TO MODIFY: /Users/matthias/work/feuerwehr_dashboard/frontend/src/pages/FahrzeugDetail.tsx + +REQUIREMENTS: +1. Add a DELETE button (red/error color) next to the existing EDIT button in the header area + - Only visible when isAdmin is true (same guard as edit button) + - Use MUI DeleteOutline or Delete icon + - Button variant: outlined, color: error + +2. Add a CONFIRMATION DIALOG (MUI Dialog component): + - Title: "Fahrzeug löschen" + - Body: "Möchten Sie das Fahrzeug '{vehicle.bezeichnung}' wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden." + - Cancel button: "Abbrechen" (default, autofocus) + - Confirm button: "Löschen" (red/error color) + +3. On confirm: + - Call vehiclesApi.delete(id) + - Show success notification (use the existing notification system/context) + - Navigate to /fahrzeuge after successful deletion + - Handle errors: show error notification if delete fails + +4. State management: + - useState for dialog open/close + - useState for loading state during delete (disable buttons) + +PATTERNS TO FOLLOW: +- Look at how the status change dialog works in the same file for patterns +- Use the existing useNotification() or similar notification hook +- Follow existing import patterns and code style +- Read the file first to understand the existing structure +``` + +### PROMPT 3: Permission Guards (P3) + +``` +You are a senior React/TypeScript developer working on a Feuerwehr Dashboard. + +TASK: Ensure features that require specific user groups are NOT VISIBLE to unauthorized users. + +CONTEXT: +- usePermissions() hook at /frontend/src/hooks/usePermissions.ts returns { isAdmin, canChangeStatus, groups } +- Groups: dashboard_admin (full access), dashboard_fahrmeister (status + wartung) +- Backend already enforces permissions via requireGroups() middleware +- The goal is to also HIDE the UI elements so users don't see options they can't use + +FILES TO CHECK AND MODIFY: + +1. /Users/matthias/work/feuerwehr_dashboard/frontend/src/pages/Fahrzeuge.tsx + - Verify: FAB "+" button only shows for isAdmin ✓ (already done) + +2. /Users/matthias/work/feuerwehr_dashboard/frontend/src/pages/FahrzeugDetail.tsx + - Verify: Edit button only shows for isAdmin ✓ (already done) + - Verify: Status change controls only show for canChangeStatus + - Verify: Wartungslog add form only shows for canChangeStatus + - ADD: Delete button guard (if prompt P2 has been applied) + +3. /Users/matthias/work/feuerwehr_dashboard/frontend/src/pages/FahrzeugForm.tsx + - ADD: If a non-admin navigates directly to /fahrzeuge/neu or /fahrzeuge/:id/bearbeiten, + show an "access denied" message or redirect to /fahrzeuge + - Use usePermissions() to check isAdmin + - Show a simple Card with "Keine Berechtigung" message and a back button + +4. /Users/matthias/work/feuerwehr_dashboard/frontend/src/App.tsx + - OPTIONAL: Consider wrapping admin-only routes with a permission-aware ProtectedRoute + - If ProtectedRoute already supports group checks, use it + - If not, the per-component check in FahrzeugForm.tsx is sufficient + +5. Navigation / sidebar: + - Check if there are navigation links that should be hidden for certain groups + - If a sidebar/drawer component exists, check visibility of menu items + +REQUIREMENTS: +- Read each file before modifying +- Do NOT change backend code +- Do NOT change the usePermissions hook signature (but you can add new computed properties) +- Use conditional rendering ({isAdmin && }) pattern consistently +- For route protection, prefer showing "Keine Berechtigung" over redirecting (less confusing UX) +``` + +### PROMPT 4: Equipment Database Migration (P4) + +``` +You are a senior PostgreSQL developer working on a Feuerwehr Dashboard backend. + +TASK: Create the database migration for the equipment management system. + +CONTEXT: +- Existing migrations are at /Users/matthias/work/feuerwehr_dashboard/backend/src/database/migrations/ +- Latest migration is 010_simplify_wartungslog_art.sql +- New migration should be 011_create_ausruestung.sql +- The database uses uuid_generate_v4() for PKs (uuid-ossp extension already enabled) +- Existing pattern: update_updated_at_column() trigger function already exists +- Vehicles table: fahrzeuge (with soft-delete via deleted_at) + +CREATE FILE: /Users/matthias/work/feuerwehr_dashboard/backend/src/database/migrations/011_create_ausruestung.sql + +SCHEMA: + +1. TABLE ausruestung_kategorien: + - id UUID PK DEFAULT uuid_generate_v4() + - name VARCHAR(100) NOT NULL UNIQUE + - kurzname VARCHAR(30) NOT NULL UNIQUE + - sortierung INTEGER NOT NULL DEFAULT 0 + - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + +2. TABLE ausruestung: + - id UUID PK DEFAULT uuid_generate_v4() + - bezeichnung VARCHAR(200) NOT NULL + - kategorie_id UUID NOT NULL FK → ausruestung_kategorien(id) + - seriennummer VARCHAR(100) + - inventarnummer VARCHAR(50) + - hersteller VARCHAR(150) + - baujahr INTEGER CHECK (1950-2100) + - status VARCHAR(30) NOT NULL DEFAULT 'einsatzbereit' CHECK IN ('einsatzbereit','beschaedigt','in_wartung','ausser_dienst') + - status_bemerkung TEXT + - ist_wichtig BOOLEAN NOT NULL DEFAULT FALSE + - fahrzeug_id UUID FK → fahrzeuge(id) ON DELETE SET NULL (nullable) + - standort VARCHAR(150) NOT NULL DEFAULT 'Lager' + - pruef_intervall_monate INTEGER CHECK > 0 + - letzte_pruefung_am DATE + - naechste_pruefung_am DATE + - bemerkung TEXT + - deleted_at TIMESTAMPTZ + - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + +3. TABLE ausruestung_wartungslog: + - id UUID PK DEFAULT uuid_generate_v4() + - ausruestung_id UUID NOT NULL FK → ausruestung(id) ON DELETE CASCADE + - datum DATE NOT NULL + - art VARCHAR(30) NOT NULL CHECK IN ('Prüfung','Reparatur','Sonstiges') + - beschreibung TEXT NOT NULL + - ergebnis VARCHAR(30) CHECK IN ('bestanden','bestanden_mit_maengeln','nicht_bestanden') + - kosten DECIMAL(8,2) CHECK >= 0 + - pruefende_stelle VARCHAR(150) + - dokument_url VARCHAR(500) + - erfasst_von UUID FK → users(id) ON DELETE SET NULL + - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + +4. VIEW ausruestung_mit_pruefstatus: + - Join ausruestung + kategorien + fahrzeuge (LEFT JOIN) + - Compute pruefung_tage_bis_faelligkeit + - Filter WHERE deleted_at IS NULL + +5. INDEXES: status, kategorie, fahrzeug, active (partial WHERE deleted_at IS NULL), pruefung, wichtig (partial composite) + +6. TRIGGER: update_updated_at_column on ausruestung + +7. SEED DATA for categories: + Atemschutzgeräte (PA), Pumpen (Pumpe), Schläuche (SL), Leitern (Leiter), + Rettungsgeräte (RG), Messgeräte (MG), PSA (PSA), Kommunikation (Funk), + Beleuchtung (Licht), Sonstige (Sonst.) + +Follow the exact SQL style of existing migrations (read 005_create_fahrzeuge.sql for reference). +``` + +### PROMPT 5: Equipment Backend (P5) + +``` +You are a senior Node.js/TypeScript developer working on a Feuerwehr Dashboard backend. + +TASK: Create the complete backend for equipment management (model, service, controller, routes). + +CONTEXT: +- Express 5 + TypeScript + PostgreSQL (raw SQL via pg pool, no ORM) +- Read existing vehicle implementation as the pattern to follow: + - Model: /backend/src/models/vehicle.model.ts + - Service: /backend/src/services/vehicle.service.ts + - Controller: /backend/src/controllers/vehicle.controller.ts + - Routes: /backend/src/routes/vehicle.routes.ts +- Database tables: ausruestung, ausruestung_kategorien, ausruestung_wartungslog (created by migration 011) +- View: ausruestung_mit_pruefstatus + +FILES TO CREATE: + +1. /backend/src/models/equipment.model.ts + - Enums: AusruestungStatus, AusruestungWartungslogArt + - Interfaces: AusruestungKategorie, Ausruestung, AusruestungListItem, AusruestungDetail, + AusruestungWartungslog, EquipmentStats, VehicleEquipmentWarning + - DTOs: CreateAusruestungData, UpdateAusruestungData, CreateAusruestungWartungslogData + +2. /backend/src/services/equipment.service.ts + Methods: + - getAllEquipment() → SELECT from view ausruestung_mit_pruefstatus + - getEquipmentById(id) → detail + wartungslog + - getEquipmentByVehicle(fahrzeugId) → equipment assigned to vehicle + - getCategories() → all categories ordered by sortierung + - createEquipment(data, createdBy) → INSERT into ausruestung + - updateEquipment(id, data, updatedBy) → dynamic PATCH + - deleteEquipment(id, deletedBy) → SET deleted_at + - updateStatus(id, status, bemerkung, updatedBy) + - addWartungslog(equipmentId, data, createdBy) + - getEquipmentStats() → counts by status + inspection alerts + - getVehicleWarnings() → important items not einsatzbereit, grouped by fahrzeug + - getUpcomingInspections(daysAhead) + +3. /backend/src/controllers/equipment.controller.ts + - Zod validation schemas (CreateAusruestungSchema, UpdateAusruestungSchema, etc.) + - Request handlers matching service methods + - Standard { success: true, data: ... } response envelope + - Error handling with try/catch and appropriate HTTP status codes + +4. /backend/src/routes/equipment.routes.ts + - GET / (list), GET /stats, GET /alerts, GET /categories + - GET /vehicle-warnings, GET /vehicle/:fahrzeugId + - GET /:id (detail) + - POST / (create, requireGroups admin+fahrmeister) + - PATCH /:id (update, requireGroups admin+fahrmeister) + - PATCH /:id/status (status change, requireGroups admin+fahrmeister) + - POST /:id/wartung (add log, requireGroups admin+fahrmeister) + - DELETE /:id (soft-delete, requireGroups admin only) + +5. MODIFY /backend/src/app.ts + - Add: import equipmentRoutes from './routes/equipment.routes' + - Add: app.use('/api/equipment', equipmentRoutes) + +IMPORTANT: +- Follow EXACT patterns from vehicle implementation (pool.query, error handling, logging) +- Use the database pool import from existing config +- Use authenticate and requireGroups middleware from existing middleware +- Read the vehicle files first to understand all patterns before writing +``` + +### PROMPT 6: Equipment Frontend Types + API Service (P6a) + +``` +You are a senior React/TypeScript developer working on a Feuerwehr Dashboard. + +TASK: Create the frontend TypeScript types and API service for equipment management. + +CONTEXT: +- Follow the exact patterns from vehicle types and service: + - Types: /frontend/src/types/vehicle.types.ts + - Service: /frontend/src/services/vehicles.ts + - API client: /frontend/src/services/api.ts (Axios instance) + +FILES TO CREATE: + +1. /frontend/src/types/equipment.types.ts + - AusruestungStatus enum (einsatzbereit, beschaedigt, in_wartung, ausser_dienst) + - AusruestungKategorie interface + - AusruestungListItem interface (from view, includes kategorie_name, fahrzeug_bezeichnung, pruefung_tage_bis_faelligkeit) + - AusruestungDetail interface (extends with wartungslog array) + - AusruestungWartungslog interface + - EquipmentStats interface + - VehicleEquipmentWarning interface + - Create/Update payload types + - All Date fields as string (JSON serialization) + +2. /frontend/src/services/equipment.ts + Methods (using api instance and unwrap pattern from vehicles.ts): + - getAll() → AusruestungListItem[] + - getById(id) → AusruestungDetail + - getByVehicle(fahrzeugId) → AusruestungListItem[] + - getCategories() → AusruestungKategorie[] + - getStats() → EquipmentStats + - getVehicleWarnings() → VehicleEquipmentWarning[] + - getAlerts(daysAhead) → InspectionAlert-like array + - create(payload) → AusruestungDetail + - update(id, payload) → AusruestungDetail + - delete(id) → void + - updateStatus(id, payload) → void + - addWartungslog(id, payload) → AusruestungWartungslog + +Read the vehicle files first to match patterns exactly. +``` + +### PROMPT 7: Equipment List Page (P6b) + +``` +You are a senior React/TypeScript developer building a Feuerwehr Dashboard with React 18 + MUI 5 + TypeScript. + +TASK: Replace the placeholder Ausruestung.tsx with a full equipment list page. + +CONTEXT: +- Existing placeholder: /frontend/src/pages/Ausruestung.tsx (just shows "coming soon") +- Follow the pattern of /frontend/src/pages/Fahrzeuge.tsx (vehicle list with cards, search, filters) +- API service: equipmentApi from /frontend/src/services/equipment.ts +- Types: from /frontend/src/types/equipment.types.ts +- Permissions: usePermissions() → { isAdmin, canChangeStatus, canManageEquipment } +- Layout: DashboardLayout wrapper +- Routing: clicking an item navigates to /ausruestung/:id + +REPLACE FILE: /frontend/src/pages/Ausruestung.tsx + +REQUIREMENTS: + +1. Stats bar at top: + - Total equipment count, einsatzbereit count, beschädigt count, prüfung fällig count + - Use Chip or small stat cards + +2. Filters: + - Search field (search across bezeichnung, seriennummer, inventarnummer, hersteller) + - Category dropdown (populated from equipmentApi.getCategories()) + - Status dropdown (all 4 statuses) + - Checkbox: "Nur wichtige" (filter ist_wichtig) + - Checkbox: "Prüfung fällig" (filter pruefung_tage_bis_faelligkeit <= 30) + +3. Table/List view (use MUI Table or Card grid matching Fahrzeuge.tsx pattern): + - Columns: Bezeichnung, Kategorie, Seriennr., Fahrzeug/Standort, Status (chip), Nächste Prüfung + - Status chips with color coding: + - einsatzbereit → success (green) + - beschaedigt → error (red) + - in_wartung → warning (orange) + - ausser_dienst → default (grey) + - Prüfung overdue → red text/chip + - Click row → navigate to /ausruestung/:id + - Important items: show star/flag icon + +4. FAB button to add new equipment: + - Only visible to canManageEquipment users + - Navigates to /ausruestung/neu + +5. Loading state with Skeleton/CircularProgress +6. Error state with retry button +7. Empty state message + +Read Fahrzeuge.tsx first to match the exact code style, imports, and patterns. +``` + +### PROMPT 8: Equipment Form Page (P7) + +``` +You are a senior React/TypeScript developer building a Feuerwehr Dashboard. + +TASK: Create the equipment create/edit form page. + +CONTEXT: +- Follow the exact pattern of /frontend/src/pages/FahrzeugForm.tsx +- Route: /ausruestung/neu (create) and /ausruestung/:id/bearbeiten (edit) +- API: equipmentApi from /frontend/src/services/equipment.ts +- Types from /frontend/src/types/equipment.types.ts +- Permission: only canManageEquipment users should see this + +CREATE FILE: /frontend/src/pages/AusruestungForm.tsx + +FORM FIELDS: +- bezeichnung (required, TextField) +- kategorie_id (required, Select dropdown from equipmentApi.getCategories()) +- seriennummer (TextField) +- inventarnummer (TextField) +- hersteller (TextField) +- baujahr (number input, 1950-2100) +- status (Select: einsatzbereit, beschaedigt, in_wartung, ausser_dienst) +- status_bemerkung (TextField multiline) +- ist_wichtig (Checkbox/Switch with label "Wichtiges Gerät (Warnung auf Fahrzeugkarte)") +- fahrzeug_id (Select dropdown, populated from vehiclesApi.getAll(), show "Kein Fahrzeug" option) +- standort (TextField, shown when no fahrzeug_id selected) +- pruef_intervall_monate (number input) +- letzte_pruefung_am (date input) +- naechste_pruefung_am (date input) +- bemerkung (TextField multiline) + +BEHAVIOR: +- Create mode: empty form, POST on submit +- Edit mode: fetch equipment by id, pre-populate all fields, PATCH on submit +- Permission check: show "Keine Berechtigung" if not canManageEquipment +- Navigate back to /ausruestung or /ausruestung/:id on success +- Validation: bezeichnung required, kategorie_id required +- Loading states during fetch and submit + +Read FahrzeugForm.tsx first to match patterns exactly. +``` + +### PROMPT 9: Equipment Detail Page (P8) + +``` +You are a senior React/TypeScript developer building a Feuerwehr Dashboard. + +TASK: Create the equipment detail page with tabs. + +CONTEXT: +- Follow the pattern of /frontend/src/pages/FahrzeugDetail.tsx +- Route: /ausruestung/:id +- API: equipmentApi from /frontend/src/services/equipment.ts + +CREATE FILE: /frontend/src/pages/AusruestungDetail.tsx + +STRUCTURE (2 tabs): + +Tab 1 — Übersicht: +- Header with bezeichnung, edit button (canManageEquipment), delete button (isAdmin) +- Status panel with current status chip + change button (canManageEquipment) +- Status change dialog (select new status + bemerkung) +- Data grid showing: Kategorie, Seriennummer, Inventarnummer, Hersteller, Baujahr, + Fahrzeug (link to /fahrzeuge/:fahrzeug_id), Standort, Wichtig flag, + Prüfintervall, Letzte Prüfung, Nächste Prüfung (with days-until color coding) +- Delete confirmation dialog (same pattern as vehicle delete from P2) + +Tab 2 — Wartung: +- Timeline/list of wartungslog entries +- Each entry shows: datum, art (chip), beschreibung, ergebnis (chip), kosten, pruefende_stelle +- Add wartungslog form (canManageEquipment only): + - datum (date), art (select), beschreibung (text), ergebnis (select optional), + kosten (number optional), pruefende_stelle (text optional) +- Sorted by date DESC + +PATTERNS: +- Read FahrzeugDetail.tsx first and follow its exact structure +- Use same notification pattern for success/error +- Use useParams() for id, useNavigate() for navigation +- Loading/error states +``` + +### PROMPT 10: Vehicle-Equipment Integration (P9 + P10) + +``` +You are a senior React/TypeScript developer working on a Feuerwehr Dashboard. + +TASK: Integrate equipment into the vehicle pages. + +CONTEXT: +- Equipment API: equipmentApi from /frontend/src/services/equipment.ts +- Equipment types from /frontend/src/types/equipment.types.ts +- Vehicle pages to modify: + - /frontend/src/pages/FahrzeugDetail.tsx (add equipment tab) + - /frontend/src/pages/Fahrzeuge.tsx (add warning badges on cards) + +PART A — Vehicle Detail Equipment Tab: + +MODIFY: /frontend/src/pages/FahrzeugDetail.tsx + +1. Add a new tab "Ausrüstung" between existing tabs (after the overview/maintenance tabs) +2. Tab label: "Ausrüstung" with a count badge showing number of assigned items +3. Tab content: + - Fetch equipment via equipmentApi.getByVehicle(vehicleId) + - Show table/list of assigned equipment: + - Bezeichnung, Kategorie, Status (chip), Nächste Prüfung + - Status chips with color coding (green/red/orange/grey) + - Important items marked with star icon + - Click navigates to /ausruestung/:id + - Empty state: "Keine Ausrüstung zugewiesen" + - Link to /ausruestung page + +PART B — Vehicle Card Warning Badges: + +MODIFY: /frontend/src/pages/Fahrzeuge.tsx + +1. On mount, fetch equipmentApi.getVehicleWarnings() alongside the vehicle list +2. Group warnings by fahrzeug_id into a Map +3. In VehicleCard component, below existing inspection badges: + - If vehicle has warnings, show a Chip: + - "1 Ausrüstung beschädigt" (red/error color) or + - "2 Ausrüstung nicht bereit" (orange/warning color) + - Tooltip showing individual item names +4. Only show if warnings array for that vehicle is non-empty + +IMPORTANT: +- Read both files completely before making changes +- Don't break existing tab indexing in FahrzeugDetail +- Handle loading states gracefully (don't block vehicle loading) +- Equipment fetch failures should not break the vehicle page (catch errors silently) +``` + +### PROMPT 11: Routing Updates (included in P6b-P9) + +``` +You are a senior React/TypeScript developer. + +TASK: Add equipment routes to the React Router configuration. + +MODIFY: /Users/matthias/work/feuerwehr_dashboard/frontend/src/App.tsx + +ADD these routes inside the existing , near the existing /ausruestung route: + +- /ausruestung → Ausruestung (already exists, will use new component) +- /ausruestung/neu → AusruestungForm (new) +- /ausruestung/:id/bearbeiten → AusruestungForm (new) +- /ausruestung/:id → AusruestungDetail (new) + +ALSO ADD: +- /ausruestung/neu BEFORE /ausruestung/:id (so "neu" isn't matched as an :id) + +Add imports for AusruestungDetail and AusruestungForm at the top of the file. +Wrap each route in following the existing pattern. + +ALSO MODIFY: /frontend/src/hooks/usePermissions.ts +- Add: canManageEquipment: groups.includes('dashboard_admin') || groups.includes('dashboard_fahrmeister') +``` + +--- + +## Step-by-Step Instructions for Matthias + +### Phase 1: Vehicle Cleanup (no backend changes, no Docker needed) + +**Step 1.1 — Remove info fields** +- Run subagent with Prompt 1 +- Files changed: FahrzeugForm.tsx, FahrzeugDetail.tsx, Fahrzeuge.tsx +- Test: Open vehicle form/detail in browser, verify fields are gone +- Commit: `feat: remove Fahrgestellnr, Standort, Besatzung, Typ, Hersteller, Baujahr from vehicle UI` + +**Step 1.2 — Add delete vehicle UI** +- Run subagent with Prompt 2 +- Files changed: FahrzeugDetail.tsx +- Test: Log in as admin, open vehicle detail, click delete, verify dialog, cancel, then test actual delete +- Commit: `feat: add delete button with confirmation dialog to vehicle detail page` + +**Step 1.3 — Permission guards** +- Run subagent with Prompt 3 +- Files changed: FahrzeugDetail.tsx, FahrzeugForm.tsx, possibly App.tsx +- Test: Log in as non-admin user, verify add/edit/delete buttons are hidden, try direct URL to /fahrzeuge/neu +- Commit: `feat: hide restricted vehicle features from unauthorized user groups` + +### Phase 2: Equipment Backend (requires Docker/PostgreSQL for migration) + +**Step 2.1 — Create database migration** +- Run subagent with Prompt 4 +- File created: backend/src/database/migrations/011_create_ausruestung.sql +- Action needed: Run migration against your PostgreSQL database + ```bash + # Connect to your database and run: + psql -U -d -f backend/src/database/migrations/011_create_ausruestung.sql + ``` +- Verify: Check that tables ausruestung, ausruestung_kategorien, ausruestung_wartungslog exist +- Verify: Check that view ausruestung_mit_pruefstatus works +- Verify: Check that seed categories were inserted +- Commit: `feat: add equipment management database schema (migration 011)` + +**Step 2.2 — Create backend model + service + controller + routes** +- Run subagent with Prompt 5 +- Files created: equipment.model.ts, equipment.service.ts, equipment.controller.ts, equipment.routes.ts +- File modified: app.ts (add route registration) +- Test: Start backend, test endpoints with curl/Postman: + ```bash + # Get categories + curl -H "Authorization: Bearer " http://localhost:3000/api/equipment/categories + # Create equipment + curl -X POST -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -d '{"bezeichnung":"Test Gerät","kategorie_id":""}' \ + http://localhost:3000/api/equipment + ``` +- Commit: `feat: add equipment management backend (model, service, controller, routes)` + +### Phase 3: Equipment Frontend (no backend changes) + +**Step 3.1 — Create frontend types + API service** +- Run subagent with Prompt 6 +- Files created: equipment.types.ts, equipment.ts (service) +- Commit: `feat: add equipment TypeScript types and API service` + +**Step 3.2 — Create equipment list page** +- Run subagent with Prompt 7 +- File replaced: Ausruestung.tsx +- Test: Navigate to /ausruestung, verify list loads (empty state initially) +- Commit: `feat: replace equipment placeholder with full list page` + +**Step 3.3 — Create equipment form** +- Run subagent with Prompt 8 +- File created: AusruestungForm.tsx +- Test: Navigate to /ausruestung/neu, fill form, submit +- Commit: `feat: add equipment create/edit form page` + +**Step 3.4 — Create equipment detail page** +- Run subagent with Prompt 9 +- File created: AusruestungDetail.tsx +- Test: Click on equipment in list, verify detail page with tabs +- Commit: `feat: add equipment detail page with tabs` + +**Step 3.5 — Update routing + permissions** +- Run subagent with Prompt 11 +- Files modified: App.tsx, usePermissions.ts +- Test: Navigate between equipment pages, verify routing works +- Commit: `feat: add equipment routes and canManageEquipment permission` + +### Phase 4: Integration (modifies vehicle pages) + +**Step 4.1 — Vehicle-equipment integration** +- Run subagent with Prompt 10 +- Files modified: FahrzeugDetail.tsx, Fahrzeuge.tsx +- Test: + 1. Assign equipment to a vehicle (via equipment form, set fahrzeug_id) + 2. Open vehicle detail → verify "Ausrüstung" tab shows the items + 3. Set an important equipment item to "beschädigt" + 4. Open vehicle list → verify warning badge appears on that vehicle's card +- Commit: `feat: add equipment tab to vehicle detail and warning badges to vehicle cards` + +### Final Verification Checklist + +- [ ] Vehicle add works (admin only) +- [ ] Vehicle edit works (admin only) +- [ ] Vehicle delete works with confirmation (admin only) +- [ ] Non-admin users cannot see add/edit/delete buttons +- [ ] Non-admin users get "Keine Berechtigung" when accessing restricted routes directly +- [ ] Removed fields (Fahrgestellnr, Standort, Besatzung, Typ, Hersteller, Baujahr) not visible +- [ ] Equipment categories load correctly +- [ ] Equipment CRUD works (create, read, update, soft-delete) +- [ ] Equipment list page with filters works +- [ ] Equipment detail page with tabs works +- [ ] Equipment wartungslog can be added +- [ ] Vehicle detail shows equipment tab with assigned items +- [ ] Vehicle cards show warning badges for non-ready important equipment +- [ ] Permission guards work: equipment management restricted to admin + fahrmeister