From 6b46e97eb6ac9dce54d35c6902ea7febc0e6fe92 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Sat, 28 Mar 2026 17:27:01 +0100 Subject: [PATCH] feat: vehicle/equipment type system, equipment checklist support, and checklist overview redesign --- backend/src/app.ts | 2 + .../controllers/ausruestungTyp.controller.ts | 130 +++++ .../src/controllers/checklist.controller.ts | 124 +++- .../migrations/070_ausruestung_typen.sql | 62 ++ .../migrations/071_checklisten_equipment.sql | 82 +++ backend/src/jobs/checklist-reminder.job.ts | 21 +- backend/src/routes/ausruestungTyp.routes.ts | 58 ++ backend/src/routes/checklist.routes.ts | 44 ++ .../src/services/ausruestungTyp.service.ts | 151 +++++ backend/src/services/checklist.service.ts | 314 ++++++++-- frontend/src/App.tsx | 18 + .../components/dashboard/ChecklistWidget.tsx | 5 +- frontend/src/components/shared/Sidebar.tsx | 21 +- frontend/src/pages/Ausruestung.tsx | 253 ++------ frontend/src/pages/AusruestungDetail.tsx | 112 +++- .../src/pages/AusruestungEinstellungen.tsx | 310 ++++++++++ frontend/src/pages/ChecklistAusfuehrung.tsx | 7 +- frontend/src/pages/Checklisten.tsx | 544 ++++++++++-------- frontend/src/pages/FahrzeugDetail.tsx | 88 ++- frontend/src/pages/FahrzeugEinstellungen.tsx | 235 ++++++++ frontend/src/services/ausruestungTypen.ts | 37 ++ frontend/src/services/checklisten.ts | 40 +- frontend/src/services/fahrzeugTypen.ts | 9 + frontend/src/types/checklist.types.ts | 47 +- frontend/src/types/equipment.types.ts | 10 + 25 files changed, 2230 insertions(+), 494 deletions(-) create mode 100644 backend/src/controllers/ausruestungTyp.controller.ts create mode 100644 backend/src/database/migrations/070_ausruestung_typen.sql create mode 100644 backend/src/database/migrations/071_checklisten_equipment.sql create mode 100644 backend/src/routes/ausruestungTyp.routes.ts create mode 100644 backend/src/services/ausruestungTyp.service.ts create mode 100644 frontend/src/pages/AusruestungEinstellungen.tsx create mode 100644 frontend/src/pages/FahrzeugEinstellungen.tsx create mode 100644 frontend/src/services/ausruestungTypen.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 145edf7..48404d0 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -106,6 +106,7 @@ import issueRoutes from './routes/issue.routes'; import buchungskategorieRoutes from './routes/buchungskategorie.routes'; import checklistRoutes from './routes/checklist.routes'; import fahrzeugTypRoutes from './routes/fahrzeugTyp.routes'; +import ausruestungTypRoutes from './routes/ausruestungTyp.routes'; app.use('/api/auth', authRoutes); app.use('/api/user', userRoutes); @@ -134,6 +135,7 @@ app.use('/api/issues', issueRoutes); app.use('/api/buchungskategorien', buchungskategorieRoutes); app.use('/api/checklisten', checklistRoutes); app.use('/api/fahrzeug-typen', fahrzeugTypRoutes); +app.use('/api/ausruestung-typen', ausruestungTypRoutes); // Static file serving for uploads (authenticated) const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads'); diff --git a/backend/src/controllers/ausruestungTyp.controller.ts b/backend/src/controllers/ausruestungTyp.controller.ts new file mode 100644 index 0000000..0f36b98 --- /dev/null +++ b/backend/src/controllers/ausruestungTyp.controller.ts @@ -0,0 +1,130 @@ +import { Request, Response } from 'express'; +import ausruestungTypService from '../services/ausruestungTyp.service'; +import logger from '../utils/logger'; + +const param = (req: Request, key: string): string => req.params[key] as string; + +class AusruestungTypController { + async getAll(_req: Request, res: Response): Promise { + try { + const types = await ausruestungTypService.getAll(); + res.status(200).json({ success: true, data: types }); + } catch (error) { + logger.error('AusruestungTypController.getAll error', { error }); + res.status(500).json({ success: false, message: 'Ausrüstungs-Typen konnten nicht geladen werden' }); + } + } + + async getById(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + return; + } + try { + const type = await ausruestungTypService.getById(id); + if (!type) { + res.status(404).json({ success: false, message: 'Ausrüstungs-Typ nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: type }); + } catch (error) { + logger.error('AusruestungTypController.getById error', { error }); + res.status(500).json({ success: false, message: 'Ausrüstungs-Typ konnte nicht geladen werden' }); + } + } + + async create(req: Request, res: Response): Promise { + const { name } = req.body; + if (!name || typeof name !== 'string' || name.trim().length === 0) { + res.status(400).json({ success: false, message: 'Name ist erforderlich' }); + return; + } + try { + const type = await ausruestungTypService.create(req.body); + res.status(201).json({ success: true, data: type }); + } catch (error) { + logger.error('AusruestungTypController.create error', { error }); + res.status(500).json({ success: false, message: 'Ausrüstungs-Typ konnte nicht erstellt werden' }); + } + } + + async update(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + return; + } + try { + const type = await ausruestungTypService.update(id, req.body); + if (!type) { + res.status(404).json({ success: false, message: 'Ausrüstungs-Typ nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: type }); + } catch (error) { + logger.error('AusruestungTypController.update error', { error }); + res.status(500).json({ success: false, message: 'Ausrüstungs-Typ konnte nicht aktualisiert werden' }); + } + } + + async deleteTyp(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + return; + } + try { + const type = await ausruestungTypService.delete(id); + if (!type) { + res.status(404).json({ success: false, message: 'Ausrüstungs-Typ nicht gefunden' }); + return; + } + res.status(200).json({ success: true, message: 'Ausrüstungs-Typ gelöscht' }); + } catch (error: any) { + if (error.message === 'Typ wird noch von Ausrüstung verwendet') { + res.status(409).json({ success: false, message: error.message }); + return; + } + logger.error('AusruestungTypController.deleteTyp error', { error }); + res.status(500).json({ success: false, message: 'Ausrüstungs-Typ konnte nicht gelöscht werden' }); + } + } + + async getTypesForEquipment(req: Request, res: Response): Promise { + const ausruestungId = param(req, 'ausruestungId'); + if (!ausruestungId) { + res.status(400).json({ success: false, message: 'Ausrüstungs-ID erforderlich' }); + return; + } + try { + const types = await ausruestungTypService.getTypesForEquipment(ausruestungId); + res.status(200).json({ success: true, data: types }); + } catch (error) { + logger.error('AusruestungTypController.getTypesForEquipment error', { error }); + res.status(500).json({ success: false, message: 'Ausrüstungs-Typen konnten nicht geladen werden' }); + } + } + + async setTypesForEquipment(req: Request, res: Response): Promise { + const ausruestungId = param(req, 'ausruestungId'); + if (!ausruestungId) { + res.status(400).json({ success: false, message: 'Ausrüstungs-ID erforderlich' }); + return; + } + const { typIds } = req.body; + if (!Array.isArray(typIds)) { + res.status(400).json({ success: false, message: 'typIds muss ein Array sein' }); + return; + } + try { + const types = await ausruestungTypService.setTypesForEquipment(ausruestungId, typIds); + res.status(200).json({ success: true, data: types }); + } catch (error) { + logger.error('AusruestungTypController.setTypesForEquipment error', { error }); + res.status(500).json({ success: false, message: 'Ausrüstungs-Typen konnten nicht gesetzt werden' }); + } + } +} + +export default new AusruestungTypController(); diff --git a/backend/src/controllers/checklist.controller.ts b/backend/src/controllers/checklist.controller.ts index 396441b..8fc8558 100644 --- a/backend/src/controllers/checklist.controller.ts +++ b/backend/src/controllers/checklist.controller.ts @@ -5,14 +5,29 @@ import logger from '../utils/logger'; const param = (req: Request, key: string): string => req.params[key] as string; class ChecklistController { + // --- Overview --- + + async getOverviewItems(_req: Request, res: Response): Promise { + try { + const data = await checklistService.getOverviewItems(); + res.status(200).json({ success: true, data }); + } catch (error) { + logger.error('ChecklistController.getOverviewItems error', { error }); + res.status(500).json({ success: false, message: 'Übersichtsdaten konnten nicht geladen werden' }); + } + } + // --- Vorlagen (Templates) --- async getVorlagen(req: Request, res: Response): Promise { try { - const filter: { fahrzeug_typ_id?: number; aktiv?: boolean } = {}; + const filter: { fahrzeug_typ_id?: number; ausruestung_typ_id?: number; aktiv?: boolean } = {}; if (req.query.fahrzeug_typ_id) { filter.fahrzeug_typ_id = parseInt(req.query.fahrzeug_typ_id as string, 10); } + if (req.query.ausruestung_typ_id) { + filter.ausruestung_typ_id = parseInt(req.query.ausruestung_typ_id as string, 10); + } if (req.query.aktiv !== undefined) { filter.aktiv = req.query.aktiv === 'true'; } @@ -263,16 +278,108 @@ class ChecklistController { } } - // --- Ausführungen (Executions) --- + // --- Templates for equipment --- - async startExecution(req: Request, res: Response): Promise { - const { fahrzeugId, vorlageId } = req.body; - if (!fahrzeugId || !vorlageId) { - res.status(400).json({ success: false, message: 'fahrzeugId und vorlageId sind erforderlich' }); + async getTemplatesForEquipment(req: Request, res: Response): Promise { + const ausruestungId = param(req, 'ausruestungId'); + if (!ausruestungId) { + res.status(400).json({ success: false, message: 'Ausrüstungs-ID erforderlich' }); return; } try { - const execution = await checklistService.startExecution(fahrzeugId, vorlageId, req.user!.id); + const templates = await checklistService.getTemplatesForEquipment(ausruestungId); + res.status(200).json({ success: true, data: templates }); + } catch (error) { + logger.error('ChecklistController.getTemplatesForEquipment error', { error }); + res.status(500).json({ success: false, message: 'Checklisten konnten nicht geladen werden' }); + } + } + + // --- Equipment-specific items --- + + async getEquipmentItems(req: Request, res: Response): Promise { + const ausruestungId = param(req, 'ausruestungId'); + if (!ausruestungId) { + res.status(400).json({ success: false, message: 'Ausrüstungs-ID erforderlich' }); + return; + } + try { + const items = await checklistService.getEquipmentItems(ausruestungId); + res.status(200).json({ success: true, data: items }); + } catch (error) { + logger.error('ChecklistController.getEquipmentItems error', { error }); + res.status(500).json({ success: false, message: 'Items konnten nicht geladen werden' }); + } + } + + async addEquipmentItem(req: Request, res: Response): Promise { + const ausruestungId = param(req, 'ausruestungId'); + if (!ausruestungId) { + res.status(400).json({ success: false, message: 'Ausrüstungs-ID erforderlich' }); + return; + } + const { bezeichnung } = req.body; + if (!bezeichnung || typeof bezeichnung !== 'string' || bezeichnung.trim().length === 0) { + res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' }); + return; + } + try { + const item = await checklistService.addEquipmentItem(ausruestungId, req.body); + res.status(201).json({ success: true, data: item }); + } catch (error) { + logger.error('ChecklistController.addEquipmentItem error', { error }); + res.status(500).json({ success: false, message: 'Item konnte nicht erstellt werden' }); + } + } + + async updateEquipmentItem(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'itemId'), 10); + if (isNaN(id)) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + return; + } + try { + const item = await checklistService.updateEquipmentItem(id, req.body); + if (!item) { + res.status(404).json({ success: false, message: 'Item nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: item }); + } catch (error) { + logger.error('ChecklistController.updateEquipmentItem error', { error }); + res.status(500).json({ success: false, message: 'Item konnte nicht aktualisiert werden' }); + } + } + + async deleteEquipmentItem(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'itemId'), 10); + if (isNaN(id)) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + return; + } + try { + const item = await checklistService.deleteEquipmentItem(id); + if (!item) { + res.status(404).json({ success: false, message: 'Item nicht gefunden' }); + return; + } + res.status(200).json({ success: true, message: 'Item deaktiviert' }); + } catch (error) { + logger.error('ChecklistController.deleteEquipmentItem error', { error }); + res.status(500).json({ success: false, message: 'Item konnte nicht deaktiviert werden' }); + } + } + + // --- Ausführungen (Executions) --- + + async startExecution(req: Request, res: Response): Promise { + const { fahrzeugId, ausruestungId, vorlageId } = req.body; + if (!vorlageId || (!fahrzeugId && !ausruestungId)) { + res.status(400).json({ success: false, message: 'vorlageId und entweder fahrzeugId oder ausruestungId sind erforderlich' }); + return; + } + try { + const execution = await checklistService.startExecution(fahrzeugId || null, vorlageId, req.user!.id, ausruestungId || null); res.status(201).json({ success: true, data: execution }); } catch (error) { logger.error('ChecklistController.startExecution error', { error }); @@ -321,8 +428,9 @@ class ChecklistController { async getExecutions(req: Request, res: Response): Promise { try { - const filter: { fahrzeugId?: string; vorlageId?: number; status?: string } = {}; + const filter: { fahrzeugId?: string; ausruestungId?: string; vorlageId?: number; status?: string } = {}; if (req.query.fahrzeugId) filter.fahrzeugId = req.query.fahrzeugId as string; + if (req.query.ausruestungId) filter.ausruestungId = req.query.ausruestungId as string; if (req.query.vorlageId) filter.vorlageId = parseInt(req.query.vorlageId as string, 10); if (req.query.status) filter.status = req.query.status as string; const executions = await checklistService.getExecutions(filter); diff --git a/backend/src/database/migrations/070_ausruestung_typen.sql b/backend/src/database/migrations/070_ausruestung_typen.sql new file mode 100644 index 0000000..a5cbddd --- /dev/null +++ b/backend/src/database/migrations/070_ausruestung_typen.sql @@ -0,0 +1,62 @@ +-- Migration 070: Ausruestung-Typen (Equipment Types) +-- Dynamic equipment type table with many-to-many junction to ausruestung. +-- Mirrors the fahrzeug_typen pattern from migration 067. +-- Seeds initial types from existing ausruestung_kategorien values. + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 1. Ausruestung-Typen +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS ausruestung_typen ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + beschreibung TEXT, + icon VARCHAR(50), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 2. Junction table (many-to-many) +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS ausruestung_ausruestung_typen ( + ausruestung_id UUID NOT NULL REFERENCES ausruestung(id) ON DELETE CASCADE, + ausruestung_typ_id INT NOT NULL REFERENCES ausruestung_typen(id) ON DELETE CASCADE, + PRIMARY KEY (ausruestung_id, ausruestung_typ_id) +); + +CREATE INDEX IF NOT EXISTS idx_aat_ausruestung_id ON ausruestung_ausruestung_typen(ausruestung_id); +CREATE INDEX IF NOT EXISTS idx_aat_typ_id ON ausruestung_ausruestung_typen(ausruestung_typ_id); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 3. Seed types from existing ausruestung_kategorien +-- ═══════════════════════════════════════════════════════════════════════════ + +INSERT INTO ausruestung_typen (name, beschreibung) + SELECT name, kurzname + FROM ausruestung_kategorien +ON CONFLICT (name) DO NOTHING; + +-- Populate junction table from existing kategorie_id assignments +INSERT INTO ausruestung_ausruestung_typen (ausruestung_id, ausruestung_typ_id) + SELECT a.id, at2.id + FROM ausruestung a + JOIN ausruestung_kategorien ak ON a.kategorie_id = ak.id + JOIN ausruestung_typen at2 ON at2.name = ak.name + WHERE a.kategorie_id IS NOT NULL + AND a.deleted_at IS NULL +ON CONFLICT DO NOTHING; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 4. Permission for managing equipment types +-- ═══════════════════════════════════════════════════════════════════════════ + +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('ausruestung:manage_types', 'ausruestung', 'Typen verwalten', 'Ausruestung-Typen erstellen und bearbeiten', 10) +ON CONFLICT (id) DO NOTHING; + +-- Grant to kommando and zeugmeister +INSERT INTO group_permissions (authentik_group, permission_id) VALUES + ('dashboard_kommando', 'ausruestung:manage_types'), + ('dashboard_zeugmeister', 'ausruestung:manage_types') +ON CONFLICT DO NOTHING; diff --git a/backend/src/database/migrations/071_checklisten_equipment.sql b/backend/src/database/migrations/071_checklisten_equipment.sql new file mode 100644 index 0000000..5ef4b1e --- /dev/null +++ b/backend/src/database/migrations/071_checklisten_equipment.sql @@ -0,0 +1,82 @@ +-- Migration 071: Checklisten Equipment Extensions +-- Extends the checklist system to support equipment (Ausruestung) alongside vehicles. +-- Depends on: 068_checklisten.sql, 070_ausruestung_typen.sql + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 1. Extend checklist_vorlagen with direct assignment columns +-- ═══════════════════════════════════════════════════════════════════════════ + +ALTER TABLE checklist_vorlagen + ADD COLUMN IF NOT EXISTS fahrzeug_id UUID REFERENCES fahrzeuge(id) ON DELETE SET NULL; + +ALTER TABLE checklist_vorlagen + ADD COLUMN IF NOT EXISTS ausruestung_id UUID REFERENCES ausruestung(id) ON DELETE SET NULL; + +ALTER TABLE checklist_vorlagen + ADD COLUMN IF NOT EXISTS ausruestung_typ_id INT REFERENCES ausruestung_typen(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_cv_fahrzeug_id ON checklist_vorlagen(fahrzeug_id); +CREATE INDEX IF NOT EXISTS idx_cv_ausruestung_id ON checklist_vorlagen(ausruestung_id); +CREATE INDEX IF NOT EXISTS idx_cv_ausruestung_typ_id ON checklist_vorlagen(ausruestung_typ_id); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 2. Extend checklist_ausfuehrungen with ausruestung support +-- ═══════════════════════════════════════════════════════════════════════════ + +-- Make fahrzeug_id nullable (was NOT NULL; now either fahrzeug or ausruestung) +ALTER TABLE checklist_ausfuehrungen + ALTER COLUMN fahrzeug_id DROP NOT NULL; + +ALTER TABLE checklist_ausfuehrungen + ADD COLUMN IF NOT EXISTS ausruestung_id UUID REFERENCES ausruestung(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_ca_ausruestung_id ON checklist_ausfuehrungen(ausruestung_id); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 3. Extend checklist_faelligkeit with ausruestung support +-- ═══════════════════════════════════════════════════════════════════════════ + +-- Make fahrzeug_id nullable (was part of composite PK) +ALTER TABLE checklist_faelligkeit + DROP CONSTRAINT IF EXISTS checklist_faelligkeit_pkey; + +ALTER TABLE checklist_faelligkeit + ALTER COLUMN fahrzeug_id DROP NOT NULL; + +ALTER TABLE checklist_faelligkeit + ADD COLUMN IF NOT EXISTS ausruestung_id UUID REFERENCES ausruestung(id) ON DELETE CASCADE; + +-- Use partial unique indexes instead of UNIQUE NULLS NOT DISTINCT (compatible with PG < 15) +CREATE UNIQUE INDEX IF NOT EXISTS idx_cf_fahrzeug_vorlage + ON checklist_faelligkeit (vorlage_id, fahrzeug_id) + WHERE fahrzeug_id IS NOT NULL AND ausruestung_id IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_cf_ausruestung_vorlage + ON checklist_faelligkeit (vorlage_id, ausruestung_id) + WHERE ausruestung_id IS NOT NULL AND fahrzeug_id IS NULL; + +CREATE INDEX IF NOT EXISTS idx_cf_ausruestung_id ON checklist_faelligkeit(ausruestung_id); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 4. Ausruestung-spezifische Checklist Items +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS ausruestung_checklist_items ( + id SERIAL PRIMARY KEY, + ausruestung_id UUID NOT NULL REFERENCES ausruestung(id) ON DELETE CASCADE, + bezeichnung VARCHAR(500) NOT NULL, + beschreibung TEXT, + pflicht BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INT NOT NULL DEFAULT 0, + aktiv BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_aci_ausruestung_id ON ausruestung_checklist_items(ausruestung_id); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 5. Extend checklist_ausfuehrung_items to reference ausruestung items +-- ═══════════════════════════════════════════════════════════════════════════ + +ALTER TABLE checklist_ausfuehrung_items + ADD COLUMN IF NOT EXISTS ausruestung_item_id INT REFERENCES ausruestung_checklist_items(id) ON DELETE SET NULL; diff --git a/backend/src/jobs/checklist-reminder.job.ts b/backend/src/jobs/checklist-reminder.job.ts index db06095..d3932aa 100644 --- a/backend/src/jobs/checklist-reminder.job.ts +++ b/backend/src/jobs/checklist-reminder.job.ts @@ -13,15 +13,19 @@ async function runChecklistReminderCheck(): Promise { } isRunning = true; try { - // Find overdue checklists + // Find overdue checklists (vehicles + equipment) const result = await pool.query(` - SELECT cf.fahrzeug_id, cf.vorlage_id, cf.naechste_faellig_am, + SELECT cf.fahrzeug_id, cf.ausruestung_id, cf.vorlage_id, cf.naechste_faellig_am, f.bezeichnung AS fahrzeug_name, + ar.name AS ausruestung_name, v.name AS vorlage_name FROM checklist_faelligkeit cf - JOIN fahrzeuge f ON f.id = cf.fahrzeug_id AND f.deleted_at IS NULL + LEFT JOIN fahrzeuge f ON f.id = cf.fahrzeug_id AND f.deleted_at IS NULL + LEFT JOIN ausruestung ar ON ar.id = cf.ausruestung_id JOIN checklist_vorlagen v ON v.id = cf.vorlage_id AND v.aktiv = true WHERE cf.naechste_faellig_am <= CURRENT_DATE + AND (cf.fahrzeug_id IS NOT NULL OR cf.ausruestung_id IS NOT NULL) + AND (cf.fahrzeug_id IS NULL OR f.id IS NOT NULL) `); if (result.rows.length === 0) return; @@ -40,16 +44,21 @@ async function runChecklistReminderCheck(): Promise { day: '2-digit', month: '2-digit', year: 'numeric', }); - // Notify first responsible user (avoid spam by using quell_id dedup) + const targetName = row.fahrzeug_name || row.ausruestung_name || 'Unbekannt'; + const quellId = row.fahrzeug_id + ? `${row.fahrzeug_id}_${row.vorlage_id}` + : `eq_${row.ausruestung_id}_${row.vorlage_id}`; + + // Notify responsible users (dedup handled by quell_id) for (const userId of targetUserIds) { await notificationService.createNotification({ user_id: userId, typ: 'checklist_faellig', titel: `Checkliste überfällig: ${row.vorlage_name}`, - nachricht: `Die Checkliste "${row.vorlage_name}" für ${row.fahrzeug_name} war fällig am ${faelligDatum}`, + nachricht: `Die Checkliste "${row.vorlage_name}" für ${targetName} war fällig am ${faelligDatum}`, schwere: 'warnung', link: `/checklisten`, - quell_id: `${row.fahrzeug_id}_${row.vorlage_id}`, + quell_id: quellId, quell_typ: 'checklist_faellig', }); } diff --git a/backend/src/routes/ausruestungTyp.routes.ts b/backend/src/routes/ausruestungTyp.routes.ts new file mode 100644 index 0000000..89b2852 --- /dev/null +++ b/backend/src/routes/ausruestungTyp.routes.ts @@ -0,0 +1,58 @@ +import { Router } from 'express'; +import ausruestungTypController from '../controllers/ausruestungTyp.controller'; +import { authenticate } from '../middleware/auth.middleware'; +import { requirePermission } from '../middleware/rbac.middleware'; + +const router = Router(); + +// List all equipment types +router.get( + '/', + authenticate, + ausruestungTypController.getAll.bind(ausruestungTypController) +); + +// Equipment-specific type management (BEFORE /:id to avoid route conflicts) +router.get( + '/equipment/:ausruestungId', + authenticate, + ausruestungTypController.getTypesForEquipment.bind(ausruestungTypController) +); + +router.put( + '/equipment/:ausruestungId', + authenticate, + requirePermission('ausruestung:manage_types'), + ausruestungTypController.setTypesForEquipment.bind(ausruestungTypController) +); + +// Get single equipment type +router.get( + '/:id', + authenticate, + ausruestungTypController.getById.bind(ausruestungTypController) +); + +// CRUD — permission-protected +router.post( + '/', + authenticate, + requirePermission('ausruestung:manage_types'), + ausruestungTypController.create.bind(ausruestungTypController) +); + +router.patch( + '/:id', + authenticate, + requirePermission('ausruestung:manage_types'), + ausruestungTypController.update.bind(ausruestungTypController) +); + +router.delete( + '/:id', + authenticate, + requirePermission('ausruestung:manage_types'), + ausruestungTypController.deleteTyp.bind(ausruestungTypController) +); + +export default router; diff --git a/backend/src/routes/checklist.routes.ts b/backend/src/routes/checklist.routes.ts index 0ceb49c..63249c6 100644 --- a/backend/src/routes/checklist.routes.ts +++ b/backend/src/routes/checklist.routes.ts @@ -13,6 +13,14 @@ router.get( checklistController.getOverdueChecklists.bind(checklistController) ); +// --- Overview --- +router.get( + '/overview', + authenticate, + requirePermission('checklisten:view'), + checklistController.getOverviewItems.bind(checklistController) +); + // --- Vorlagen (Templates) --- router.get( '/vorlagen', @@ -124,6 +132,42 @@ router.get( checklistController.getDueChecklists.bind(checklistController) ); +// --- Equipment-specific items --- +router.get( + '/equipment/:ausruestungId/vorlagen', + authenticate, + requirePermission('checklisten:view'), + checklistController.getTemplatesForEquipment.bind(checklistController) +); + +router.get( + '/equipment/:ausruestungId/items', + authenticate, + requirePermission('checklisten:view'), + checklistController.getEquipmentItems.bind(checklistController) +); + +router.post( + '/equipment/:ausruestungId/items', + authenticate, + requirePermission('checklisten:manage_templates'), + checklistController.addEquipmentItem.bind(checklistController) +); + +router.patch( + '/equipment/:ausruestungId/items/:itemId', + authenticate, + requirePermission('checklisten:manage_templates'), + checklistController.updateEquipmentItem.bind(checklistController) +); + +router.delete( + '/equipment/:ausruestungId/items/:itemId', + authenticate, + requirePermission('checklisten:manage_templates'), + checklistController.deleteEquipmentItem.bind(checklistController) +); + // --- Ausführungen (Executions) --- router.get( '/ausfuehrungen', diff --git a/backend/src/services/ausruestungTyp.service.ts b/backend/src/services/ausruestungTyp.service.ts new file mode 100644 index 0000000..899d01b --- /dev/null +++ b/backend/src/services/ausruestungTyp.service.ts @@ -0,0 +1,151 @@ +import pool from '../config/database'; +import logger from '../utils/logger'; + +class AusruestungTypService { + async getAll() { + try { + const result = await pool.query( + `SELECT * FROM ausruestung_typen ORDER BY name ASC` + ); + return result.rows; + } catch (error) { + logger.error('AusruestungTypService.getAll failed', { error }); + throw new Error('Ausrüstungs-Typen konnten nicht geladen werden'); + } + } + + async getById(id: number) { + try { + const result = await pool.query( + `SELECT * FROM ausruestung_typen WHERE id = $1`, + [id] + ); + return result.rows[0] || null; + } catch (error) { + logger.error('AusruestungTypService.getById failed', { error, id }); + throw new Error('Ausrüstungs-Typ konnte nicht geladen werden'); + } + } + + async create(data: { name: string; beschreibung?: string; icon?: string }) { + try { + const result = await pool.query( + `INSERT INTO ausruestung_typen (name, beschreibung, icon) + VALUES ($1, $2, $3) + RETURNING *`, + [data.name, data.beschreibung ?? null, data.icon ?? null] + ); + return result.rows[0]; + } catch (error) { + logger.error('AusruestungTypService.create failed', { error }); + throw new Error('Ausrüstungs-Typ konnte nicht erstellt werden'); + } + } + + async update(id: number, data: { name?: string; beschreibung?: string; icon?: string }) { + try { + const setClauses: string[] = []; + const values: any[] = []; + let idx = 1; + + if (data.name !== undefined) { + setClauses.push(`name = $${idx}`); + values.push(data.name); + idx++; + } + if (data.beschreibung !== undefined) { + setClauses.push(`beschreibung = $${idx}`); + values.push(data.beschreibung); + idx++; + } + if (data.icon !== undefined) { + setClauses.push(`icon = $${idx}`); + values.push(data.icon); + idx++; + } + + if (setClauses.length === 0) { + return this.getById(id); + } + + values.push(id); + const result = await pool.query( + `UPDATE ausruestung_typen SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ); + return result.rows[0] || null; + } catch (error) { + logger.error('AusruestungTypService.update failed', { error, id }); + throw new Error('Ausrüstungs-Typ konnte nicht aktualisiert werden'); + } + } + + async delete(id: number) { + try { + // Check if any equipment uses this type + const usage = await pool.query( + `SELECT COUNT(*) FROM ausruestung_ausruestung_typen WHERE ausruestung_typ_id = $1`, + [id] + ); + if (parseInt(usage.rows[0].count, 10) > 0) { + throw new Error('Typ wird noch von Ausrüstung verwendet'); + } + + const result = await pool.query( + `DELETE FROM ausruestung_typen WHERE id = $1 RETURNING *`, + [id] + ); + return result.rows[0] || null; + } catch (error) { + logger.error('AusruestungTypService.delete failed', { error, id }); + throw error; + } + } + + async getTypesForEquipment(ausruestungId: string) { + try { + const result = await pool.query( + `SELECT at.* FROM ausruestung_typen at + JOIN ausruestung_ausruestung_typen aat ON aat.ausruestung_typ_id = at.id + WHERE aat.ausruestung_id = $1 + ORDER BY at.name ASC`, + [ausruestungId] + ); + return result.rows; + } catch (error) { + logger.error('AusruestungTypService.getTypesForEquipment failed', { error, ausruestungId }); + throw new Error('Ausrüstungs-Typen konnten nicht geladen werden'); + } + } + + async setTypesForEquipment(ausruestungId: string, typIds: number[]) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + await client.query( + `DELETE FROM ausruestung_ausruestung_typen WHERE ausruestung_id = $1`, + [ausruestungId] + ); + + for (const typId of typIds) { + await client.query( + `INSERT INTO ausruestung_ausruestung_typen (ausruestung_id, ausruestung_typ_id) VALUES ($1, $2)`, + [ausruestungId, typId] + ); + } + + await client.query('COMMIT'); + + return this.getTypesForEquipment(ausruestungId); + } catch (error) { + await client.query('ROLLBACK').catch(() => {}); + logger.error('AusruestungTypService.setTypesForEquipment failed', { error, ausruestungId }); + throw new Error('Ausrüstungs-Typen konnten nicht gesetzt werden'); + } finally { + client.release(); + } + } +} + +export default new AusruestungTypService(); diff --git a/backend/src/services/checklist.service.ts b/backend/src/services/checklist.service.ts index 2c80c43..bc69de4 100644 --- a/backend/src/services/checklist.service.ts +++ b/backend/src/services/checklist.service.ts @@ -30,7 +30,7 @@ function calculateNextDueDate(intervall: string | null, intervall_tage: number | // Vorlagen (Templates) // --------------------------------------------------------------------------- -async function getVorlagen(filter?: { fahrzeug_typ_id?: number; aktiv?: boolean }) { +async function getVorlagen(filter?: { fahrzeug_typ_id?: number; ausruestung_typ_id?: number; aktiv?: boolean }) { try { const conditions: string[] = []; const values: any[] = []; @@ -41,6 +41,11 @@ async function getVorlagen(filter?: { fahrzeug_typ_id?: number; aktiv?: boolean values.push(filter.fahrzeug_typ_id); idx++; } + if (filter?.ausruestung_typ_id !== undefined) { + conditions.push(`v.ausruestung_typ_id = $${idx}`); + values.push(filter.ausruestung_typ_id); + idx++; + } if (filter?.aktiv !== undefined) { conditions.push(`v.aktiv = $${idx}`); values.push(filter.aktiv); @@ -49,9 +54,12 @@ async function getVorlagen(filter?: { fahrzeug_typ_id?: number; aktiv?: boolean const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const result = await pool.query( - `SELECT v.*, ft.name AS fahrzeug_typ_name + `SELECT v.*, + ft.name AS fahrzeug_typ_name, + at.name AS ausruestung_typ_name FROM checklist_vorlagen v LEFT JOIN fahrzeug_typen ft ON ft.id = v.fahrzeug_typ_id + LEFT JOIN ausruestung_typen at ON at.id = v.ausruestung_typ_id ${where} ORDER BY v.name ASC`, values @@ -66,9 +74,12 @@ async function getVorlagen(filter?: { fahrzeug_typ_id?: number; aktiv?: boolean async function getVorlageById(id: number) { try { const vorlageResult = await pool.query( - `SELECT v.*, ft.name AS fahrzeug_typ_name + `SELECT v.*, + ft.name AS fahrzeug_typ_name, + at.name AS ausruestung_typ_name FROM checklist_vorlagen v LEFT JOIN fahrzeug_typen ft ON ft.id = v.fahrzeug_typ_id + LEFT JOIN ausruestung_typen at ON at.id = v.ausruestung_typ_id WHERE v.id = $1`, [id] ); @@ -90,18 +101,24 @@ async function getVorlageById(id: number) { async function createVorlage(data: { name: string; fahrzeug_typ_id?: number | null; + fahrzeug_id?: string | null; + ausruestung_typ_id?: number | null; + ausruestung_id?: string | null; intervall?: string | null; intervall_tage?: number | null; beschreibung?: string | null; }) { try { const result = await pool.query( - `INSERT INTO checklist_vorlagen (name, fahrzeug_typ_id, intervall, intervall_tage, beschreibung) - VALUES ($1, $2, $3, $4, $5) + `INSERT INTO checklist_vorlagen (name, fahrzeug_typ_id, fahrzeug_id, ausruestung_typ_id, ausruestung_id, intervall, intervall_tage, beschreibung) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [ data.name, data.fahrzeug_typ_id ?? null, + data.fahrzeug_id ?? null, + data.ausruestung_typ_id ?? null, + data.ausruestung_id ?? null, data.intervall ?? null, data.intervall_tage ?? null, data.beschreibung ?? null, @@ -117,6 +134,9 @@ async function createVorlage(data: { async function updateVorlage(id: number, data: { name?: string; fahrzeug_typ_id?: number | null; + fahrzeug_id?: string | null; + ausruestung_typ_id?: number | null; + ausruestung_id?: string | null; intervall?: string | null; intervall_tage?: number | null; beschreibung?: string | null; @@ -129,6 +149,9 @@ async function updateVorlage(id: number, data: { if (data.name !== undefined) { setClauses.push(`name = $${idx}`); values.push(data.name); idx++; } if ('fahrzeug_typ_id' in data) { setClauses.push(`fahrzeug_typ_id = $${idx}`); values.push(data.fahrzeug_typ_id); idx++; } + if ('fahrzeug_id' in data) { setClauses.push(`fahrzeug_id = $${idx}`); values.push(data.fahrzeug_id); idx++; } + if ('ausruestung_typ_id' in data) { setClauses.push(`ausruestung_typ_id = $${idx}`); values.push(data.ausruestung_typ_id); idx++; } + if ('ausruestung_id' in data) { setClauses.push(`ausruestung_id = $${idx}`); values.push(data.ausruestung_id); idx++; } if ('intervall' in data) { setClauses.push(`intervall = $${idx}`); values.push(data.intervall); idx++; } if ('intervall_tage' in data) { setClauses.push(`intervall_tage = $${idx}`); values.push(data.intervall_tage); idx++; } if ('beschreibung' in data) { setClauses.push(`beschreibung = $${idx}`); values.push(data.beschreibung); idx++; } @@ -329,23 +352,111 @@ async function deleteVehicleItem(id: number) { } } +// --------------------------------------------------------------------------- +// Ausrüstung-spezifische Items +// --------------------------------------------------------------------------- + +async function getEquipmentItems(ausruestungId: string) { + try { + const result = await pool.query( + `SELECT * FROM ausruestung_checklist_items WHERE ausruestung_id = $1 AND aktiv = true ORDER BY sort_order ASC, id ASC`, + [ausruestungId] + ); + return result.rows; + } catch (error) { + logger.error('ChecklistService.getEquipmentItems failed', { error, ausruestungId }); + throw new Error('Ausrüstungs-Items konnten nicht geladen werden'); + } +} + +async function addEquipmentItem(ausruestungId: string, data: { + bezeichnung: string; + beschreibung?: string | null; + pflicht?: boolean; + sort_order?: number; +}) { + try { + const result = await pool.query( + `INSERT INTO ausruestung_checklist_items (ausruestung_id, bezeichnung, beschreibung, pflicht, sort_order) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [ausruestungId, data.bezeichnung, data.beschreibung ?? null, data.pflicht ?? true, data.sort_order ?? 0] + ); + return result.rows[0]; + } catch (error) { + logger.error('ChecklistService.addEquipmentItem failed', { error, ausruestungId }); + throw new Error('Ausrüstungs-Item konnte nicht erstellt werden'); + } +} + +async function updateEquipmentItem(id: number, data: { + bezeichnung?: string; + beschreibung?: string | null; + pflicht?: boolean; + sort_order?: number; + aktiv?: boolean; +}) { + try { + const setClauses: string[] = []; + const values: any[] = []; + let idx = 1; + + if (data.bezeichnung !== undefined) { setClauses.push(`bezeichnung = $${idx}`); values.push(data.bezeichnung); idx++; } + if ('beschreibung' in data) { setClauses.push(`beschreibung = $${idx}`); values.push(data.beschreibung); idx++; } + if (data.pflicht !== undefined) { setClauses.push(`pflicht = $${idx}`); values.push(data.pflicht); idx++; } + if (data.sort_order !== undefined) { setClauses.push(`sort_order = $${idx}`); values.push(data.sort_order); idx++; } + if (data.aktiv !== undefined) { setClauses.push(`aktiv = $${idx}`); values.push(data.aktiv); idx++; } + + if (setClauses.length === 0) { + const r = await pool.query(`SELECT * FROM ausruestung_checklist_items WHERE id = $1`, [id]); + return r.rows[0] || null; + } + + values.push(id); + const result = await pool.query( + `UPDATE ausruestung_checklist_items SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ); + return result.rows[0] || null; + } catch (error) { + logger.error('ChecklistService.updateEquipmentItem failed', { error, id }); + throw new Error('Ausrüstungs-Item konnte nicht aktualisiert werden'); + } +} + +async function deleteEquipmentItem(id: number) { + try { + const result = await pool.query( + `UPDATE ausruestung_checklist_items SET aktiv = false WHERE id = $1 RETURNING *`, + [id] + ); + return result.rows[0] || null; + } catch (error) { + logger.error('ChecklistService.deleteEquipmentItem failed', { error, id }); + throw new Error('Ausrüstungs-Item konnte nicht deaktiviert werden'); + } +} + // --------------------------------------------------------------------------- // Templates for a specific vehicle (via type junction) // --------------------------------------------------------------------------- async function getTemplatesForVehicle(fahrzeugId: string) { try { - // Templates that match the vehicle's types, or global templates (no type) + // Templates that match the vehicle directly, by type, or global (no assignment) const result = await pool.query( `SELECT DISTINCT v.*, ft.name AS fahrzeug_typ_name FROM checklist_vorlagen v LEFT JOIN fahrzeug_typen ft ON ft.id = v.fahrzeug_typ_id WHERE v.aktiv = true + AND v.ausruestung_id IS NULL + AND v.ausruestung_typ_id IS NULL AND ( - v.fahrzeug_typ_id IS NULL + v.fahrzeug_id = $1 OR v.fahrzeug_typ_id IN ( SELECT fahrzeug_typ_id FROM fahrzeug_fahrzeug_typen WHERE fahrzeug_id = $1 ) + OR (v.fahrzeug_typ_id IS NULL AND v.fahrzeug_id IS NULL) ) ORDER BY v.name ASC`, [fahrzeugId] @@ -367,21 +478,109 @@ async function getTemplatesForVehicle(fahrzeugId: string) { } } +// --------------------------------------------------------------------------- +// Templates for a specific equipment item (via type junction) +// --------------------------------------------------------------------------- + +async function getTemplatesForEquipment(ausruestungId: string) { + try { + const result = await pool.query( + `SELECT DISTINCT v.*, at.name AS ausruestung_typ_name + FROM checklist_vorlagen v + LEFT JOIN ausruestung_typen at ON at.id = v.ausruestung_typ_id + WHERE v.aktiv = true + AND ( + v.ausruestung_id = $1 + OR v.ausruestung_typ_id IN ( + SELECT ausruestung_typ_id FROM ausruestung_ausruestung_typen WHERE ausruestung_id = $1 + ) + OR (v.fahrzeug_typ_id IS NULL AND v.fahrzeug_id IS NULL AND v.ausruestung_typ_id IS NULL AND v.ausruestung_id IS NULL) + ) + ORDER BY v.name ASC`, + [ausruestungId] + ); + + // Attach items to each template + for (const vorlage of result.rows) { + const items = await pool.query( + `SELECT * FROM checklist_vorlage_items WHERE vorlage_id = $1 ORDER BY sort_order ASC, id ASC`, + [vorlage.id] + ); + vorlage.items = items.rows; + } + + return result.rows; + } catch (error) { + logger.error('ChecklistService.getTemplatesForEquipment failed', { error, ausruestungId }); + throw new Error('Checklisten für Ausrüstung konnten nicht geladen werden'); + } +} + +// --------------------------------------------------------------------------- +// Overview Items (vehicles + equipment with open checklists) +// --------------------------------------------------------------------------- + +async function getOverviewItems() { + try { + // Vehicles with overdue or upcoming checklists (within 7 days) + const vehiclesResult = await pool.query(` + SELECT f.id, f.bezeichnung AS name, f.kurzname, + json_agg(json_build_object( + 'vorlage_id', cf.vorlage_id, + 'vorlage_name', v.name, + 'next_due', cf.naechste_faellig_am + ) ORDER BY cf.naechste_faellig_am ASC) AS checklists + FROM checklist_faelligkeit cf + JOIN fahrzeuge f ON f.id = cf.fahrzeug_id AND f.deleted_at IS NULL + JOIN checklist_vorlagen v ON v.id = cf.vorlage_id AND v.aktiv = true + WHERE cf.fahrzeug_id IS NOT NULL + AND cf.naechste_faellig_am <= CURRENT_DATE + INTERVAL '7 days' + GROUP BY f.id, f.bezeichnung, f.kurzname + ORDER BY MIN(cf.naechste_faellig_am) ASC + `); + + // Equipment with overdue or upcoming checklists (within 7 days) + const equipmentResult = await pool.query(` + SELECT a.id, a.name, + json_agg(json_build_object( + 'vorlage_id', cf.vorlage_id, + 'vorlage_name', v.name, + 'next_due', cf.naechste_faellig_am + ) ORDER BY cf.naechste_faellig_am ASC) AS checklists + FROM checklist_faelligkeit cf + JOIN ausruestung a ON a.id = cf.ausruestung_id + JOIN checklist_vorlagen v ON v.id = cf.vorlage_id AND v.aktiv = true + WHERE cf.ausruestung_id IS NOT NULL + AND cf.naechste_faellig_am <= CURRENT_DATE + INTERVAL '7 days' + GROUP BY a.id, a.name + ORDER BY MIN(cf.naechste_faellig_am) ASC + `); + + return { + vehicles: vehiclesResult.rows, + equipment: equipmentResult.rows, + }; + } catch (error) { + logger.error('ChecklistService.getOverviewItems failed', { error }); + throw new Error('Übersichtsdaten konnten nicht geladen werden'); + } +} + // --------------------------------------------------------------------------- // Ausführungen (Executions) // --------------------------------------------------------------------------- -async function startExecution(fahrzeugId: string, vorlageId: number, userId: string) { +async function startExecution(fahrzeugId: string | null, vorlageId: number, userId: string, ausruestungId?: string | null) { const client = await pool.connect(); try { await client.query('BEGIN'); // Create the execution record const execResult = await client.query( - `INSERT INTO checklist_ausfuehrungen (fahrzeug_id, vorlage_id, ausgefuehrt_von) - VALUES ($1, $2, $3) + `INSERT INTO checklist_ausfuehrungen (fahrzeug_id, ausruestung_id, vorlage_id, ausgefuehrt_von) + VALUES ($1, $2, $3, $4) RETURNING *`, - [fahrzeugId, vorlageId, userId] + [fahrzeugId || null, ausruestungId || null, vorlageId, userId] ); const execution = execResult.rows[0]; @@ -398,17 +597,31 @@ async function startExecution(fahrzeugId: string, vorlageId: number, userId: str ); } - // Copy vehicle-specific items - const vehicleItems = await client.query( - `SELECT * FROM fahrzeug_checklist_items WHERE fahrzeug_id = $1 AND aktiv = true ORDER BY sort_order ASC, id ASC`, - [fahrzeugId] - ); - for (const item of vehicleItems.rows) { - await client.query( - `INSERT INTO checklist_ausfuehrung_items (ausfuehrung_id, fahrzeug_item_id, bezeichnung) - VALUES ($1, $2, $3)`, - [execution.id, item.id, item.bezeichnung] + // Copy entity-specific items (vehicle or equipment) + if (ausruestungId) { + const equipmentItems = await client.query( + `SELECT * FROM ausruestung_checklist_items WHERE ausruestung_id = $1 AND aktiv = true ORDER BY sort_order ASC, id ASC`, + [ausruestungId] ); + for (const item of equipmentItems.rows) { + await client.query( + `INSERT INTO checklist_ausfuehrung_items (ausfuehrung_id, ausruestung_item_id, bezeichnung) + VALUES ($1, $2, $3)`, + [execution.id, item.id, item.bezeichnung] + ); + } + } else if (fahrzeugId) { + const vehicleItems = await client.query( + `SELECT * FROM fahrzeug_checklist_items WHERE fahrzeug_id = $1 AND aktiv = true ORDER BY sort_order ASC, id ASC`, + [fahrzeugId] + ); + for (const item of vehicleItems.rows) { + await client.query( + `INSERT INTO checklist_ausfuehrung_items (ausfuehrung_id, fahrzeug_item_id, bezeichnung) + VALUES ($1, $2, $3)`, + [execution.id, item.id, item.bezeichnung] + ); + } } await client.query('COMMIT'); @@ -429,11 +642,13 @@ async function getExecutionById(id: string) { const execResult = await pool.query( `SELECT a.*, f.bezeichnung AS fahrzeug_name, f.kurzname AS fahrzeug_kurzname, + ar.name AS ausruestung_name, v.name AS vorlage_name, u1.name AS ausgefuehrt_von_name, u2.name AS freigegeben_von_name FROM checklist_ausfuehrungen a LEFT JOIN fahrzeuge f ON f.id = a.fahrzeug_id + LEFT JOIN ausruestung ar ON ar.id = a.ausruestung_id LEFT JOIN checklist_vorlagen v ON v.id = a.vorlage_id LEFT JOIN users u1 ON u1.id = a.ausgefuehrt_von LEFT JOIN users u2 ON u2.id = a.freigegeben_von @@ -475,12 +690,13 @@ async function submitExecution( // Check if all pflicht items have ergebnis = 'ok' const pflichtCheck = await client.query( - `SELECT ai.id, ai.ergebnis, ai.vorlage_item_id, ai.fahrzeug_item_id + `SELECT ai.id, ai.ergebnis, ai.vorlage_item_id, ai.fahrzeug_item_id, ai.ausruestung_item_id FROM checklist_ausfuehrung_items ai LEFT JOIN checklist_vorlage_items vi ON vi.id = ai.vorlage_item_id LEFT JOIN fahrzeug_checklist_items fi ON fi.id = ai.fahrzeug_item_id + LEFT JOIN ausruestung_checklist_items aci ON aci.id = ai.ausruestung_item_id WHERE ai.ausfuehrung_id = $1 - AND (COALESCE(vi.pflicht, fi.pflicht, true) = true)`, + AND (COALESCE(vi.pflicht, fi.pflicht, aci.pflicht, true) = true)`, [id] ); @@ -494,20 +710,30 @@ async function submitExecution( // Update checklist_faelligkeit if completed if (allPflichtOk) { - const exec = await client.query(`SELECT vorlage_id, fahrzeug_id FROM checklist_ausfuehrungen WHERE id = $1`, [id]); + const exec = await client.query(`SELECT vorlage_id, fahrzeug_id, ausruestung_id FROM checklist_ausfuehrungen WHERE id = $1`, [id]); if (exec.rows.length > 0) { - const { vorlage_id, fahrzeug_id } = exec.rows[0]; + const { vorlage_id, fahrzeug_id, ausruestung_id } = exec.rows[0]; const vorlage = await client.query(`SELECT intervall, intervall_tage FROM checklist_vorlagen WHERE id = $1`, [vorlage_id]); if (vorlage.rows.length > 0) { const nextDue = calculateNextDueDate(vorlage.rows[0].intervall, vorlage.rows[0].intervall_tage); if (nextDue) { - await client.query( - `INSERT INTO checklist_faelligkeit (fahrzeug_id, vorlage_id, naechste_faellig_am, letzte_ausfuehrung_id) - VALUES ($1, $2, $3, $4) - ON CONFLICT (fahrzeug_id, vorlage_id) DO UPDATE - SET naechste_faellig_am = $3, letzte_ausfuehrung_id = $4`, - [fahrzeug_id, vorlage_id, nextDue, id] - ); + if (ausruestung_id) { + await client.query( + `INSERT INTO checklist_faelligkeit (ausruestung_id, vorlage_id, naechste_faellig_am, letzte_ausfuehrung_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (vorlage_id, ausruestung_id) WHERE ausruestung_id IS NOT NULL AND fahrzeug_id IS NULL + DO UPDATE SET naechste_faellig_am = $3, letzte_ausfuehrung_id = $4`, + [ausruestung_id, vorlage_id, nextDue, id] + ); + } else if (fahrzeug_id) { + await client.query( + `INSERT INTO checklist_faelligkeit (fahrzeug_id, vorlage_id, naechste_faellig_am, letzte_ausfuehrung_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (vorlage_id, fahrzeug_id) WHERE fahrzeug_id IS NOT NULL AND ausruestung_id IS NULL + DO UPDATE SET naechste_faellig_am = $3, letzte_ausfuehrung_id = $4`, + [fahrzeug_id, vorlage_id, nextDue, id] + ); + } } } } @@ -541,7 +767,7 @@ async function approveExecution(id: string, userId: string) { } } -async function getExecutions(filter?: { fahrzeugId?: string; vorlageId?: number; status?: string }) { +async function getExecutions(filter?: { fahrzeugId?: string; ausruestungId?: string; vorlageId?: number; status?: string }) { try { const conditions: string[] = []; const values: any[] = []; @@ -552,6 +778,11 @@ async function getExecutions(filter?: { fahrzeugId?: string; vorlageId?: number; values.push(filter.fahrzeugId); idx++; } + if (filter?.ausruestungId) { + conditions.push(`a.ausruestung_id = $${idx}`); + values.push(filter.ausruestungId); + idx++; + } if (filter?.vorlageId) { conditions.push(`a.vorlage_id = $${idx}`); values.push(filter.vorlageId); @@ -567,11 +798,13 @@ async function getExecutions(filter?: { fahrzeugId?: string; vorlageId?: number; const result = await pool.query( `SELECT a.*, f.bezeichnung AS fahrzeug_name, f.kurzname AS fahrzeug_kurzname, + ar.name AS ausruestung_name, v.name AS vorlage_name, u1.name AS ausgefuehrt_von_name, u2.name AS freigegeben_von_name FROM checklist_ausfuehrungen a LEFT JOIN fahrzeuge f ON f.id = a.fahrzeug_id + LEFT JOIN ausruestung ar ON ar.id = a.ausruestung_id LEFT JOIN checklist_vorlagen v ON v.id = a.vorlage_id LEFT JOIN users u1 ON u1.id = a.ausgefuehrt_von LEFT JOIN users u2 ON u2.id = a.freigegeben_von @@ -593,12 +826,17 @@ async function getExecutions(filter?: { fahrzeugId?: string; vorlageId?: number; async function getOverdueChecklists() { try { const result = await pool.query(` - SELECT cf.*, f.bezeichnung AS fahrzeug_name, f.kurzname AS fahrzeug_kurzname, + SELECT cf.*, + f.bezeichnung AS fahrzeug_name, f.kurzname AS fahrzeug_kurzname, + ar.name AS ausruestung_name, v.name AS vorlage_name FROM checklist_faelligkeit cf - JOIN fahrzeuge f ON f.id = cf.fahrzeug_id AND f.deleted_at IS NULL + LEFT JOIN fahrzeuge f ON f.id = cf.fahrzeug_id AND f.deleted_at IS NULL + LEFT JOIN ausruestung ar ON ar.id = cf.ausruestung_id JOIN checklist_vorlagen v ON v.id = cf.vorlage_id AND v.aktiv = true WHERE cf.naechste_faellig_am <= CURRENT_DATE + AND (cf.fahrzeug_id IS NOT NULL OR cf.ausruestung_id IS NOT NULL) + AND (cf.fahrzeug_id IS NULL OR f.id IS NOT NULL) ORDER BY cf.naechste_faellig_am ASC `); return result.rows; @@ -639,7 +877,13 @@ export default { addVehicleItem, updateVehicleItem, deleteVehicleItem, + getEquipmentItems, + addEquipmentItem, + updateEquipmentItem, + deleteEquipmentItem, getTemplatesForVehicle, + getTemplatesForEquipment, + getOverviewItems, startExecution, getExecutionById, submitExecution, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c8dc377..3718eb5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,7 @@ import BookingFormPage from './pages/BookingFormPage'; import Ausruestung from './pages/Ausruestung'; import AusruestungForm from './pages/AusruestungForm'; import AusruestungDetail from './pages/AusruestungDetail'; +import AusruestungEinstellungen from './pages/AusruestungEinstellungen'; import Atemschutz from './pages/Atemschutz'; import Mitglieder from './pages/Mitglieder'; import MitgliedDetail from './pages/MitgliedDetail'; @@ -37,6 +38,7 @@ import AusruestungsanfrageZuBestellung from './pages/AusruestungsanfrageZuBestel import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail'; import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu'; import Checklisten from './pages/Checklisten'; +import FahrzeugEinstellungen from './pages/FahrzeugEinstellungen'; import ChecklistAusfuehrung from './pages/ChecklistAusfuehrung'; import Issues from './pages/Issues'; import IssueDetail from './pages/IssueDetail'; @@ -120,6 +122,14 @@ function App() { } /> + + + + } + /> } /> + + + + } + /> {overdueItems.slice(0, 5).map((item) => { const days = Math.ceil((Date.now() - new Date(item.naechste_faellig_am).getTime()) / 86400000); + const targetName = item.fahrzeug_name || item.ausruestung_name || '–'; return ( - + - {item.fahrzeug_name} + {targetName} 0 ? `${days}d` : 'heute'}`} diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx index 7baaf84..162ae40 100644 --- a/frontend/src/components/shared/Sidebar.tsx +++ b/frontend/src/components/shared/Sidebar.tsx @@ -192,12 +192,17 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { const menuOrder: string[] = (preferences?.menuOrder as string[] | undefined) ?? []; const vehicleSubItems: SubItem[] = useMemo( - () => - (vehicleList ?? []).map((v) => ({ + () => { + const items: SubItem[] = (vehicleList ?? []).map((v) => ({ text: v.bezeichnung ?? v.kurzname, path: `/fahrzeuge/${v.id}`, - })), - [vehicleList], + })); + if (hasPermission('fahrzeuge:edit')) { + items.push({ text: 'Einstellungen', path: '/fahrzeuge/einstellungen' }); + } + return items; + }, + [vehicleList, hasPermission], ); const navigationItems = useMemo((): NavigationItem[] => { @@ -234,13 +239,19 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { ]; if (hasPermission('checklisten:manage_templates')) { checklistenSubItems.push({ text: 'Vorlagen', path: '/checklisten?tab=1' }); - checklistenSubItems.push({ text: 'Fahrzeugtypen', path: '/checklisten?tab=2' }); } checklistenSubItems.push({ text: 'Historie', path: `/checklisten?tab=${checklistenSubItems.length}` }); const items = baseNavigationItems .map((item) => { if (item.path === '/fahrzeuge') return fahrzeugeItem; + if (item.path === '/ausruestung') { + const ausruestungSubs: SubItem[] = []; + if (hasPermission('ausruestung:manage_types')) { + ausruestungSubs.push({ text: 'Einstellungen', path: '/ausruestung/einstellungen' }); + } + return ausruestungSubs.length > 0 ? { ...item, subItems: ausruestungSubs } : item; + } if (item.path === '/ausruestungsanfrage') { const canSeeAusruestung = hasPermission('ausruestungsanfrage:view') || diff --git a/frontend/src/pages/Ausruestung.tsx b/frontend/src/pages/Ausruestung.tsx index a5a0e69..3697b39 100644 --- a/frontend/src/pages/Ausruestung.tsx +++ b/frontend/src/pages/Ausruestung.tsx @@ -9,10 +9,6 @@ import { Chip, CircularProgress, Container, - Dialog, - DialogActions, - DialogContent, - DialogTitle, FormControl, FormControlLabel, Grid, @@ -22,12 +18,6 @@ import { MenuItem, Select, Switch, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, TextField, Tooltip, Typography, @@ -36,22 +26,20 @@ import { Add, Build, CheckCircle, - Close, - Delete, - Edit, Error as ErrorIcon, LinkRounded, PauseCircle, RemoveCircle, - Save, Search, Settings, Star, Warning, } from '@mui/icons-material'; import { useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import { equipmentApi } from '../services/equipment'; +import { ausruestungTypenApi } from '../services/ausruestungTypen'; import { AusruestungListItem, AusruestungKategorie, @@ -60,7 +48,6 @@ import { EquipmentStats, } from '../types/equipment.types'; import { usePermissions } from '../hooks/usePermissions'; -import { useNotification } from '../contexts/NotificationContext'; import ChatAwareFab from '../components/shared/ChatAwareFab'; // ── Status chip config ──────────────────────────────────────────────────────── @@ -169,13 +156,23 @@ const EquipmentCard: React.FC = ({ item, onClick }) => { {item.bezeichnung} - + + {item.typen?.map((t) => ( + + ))} @@ -236,186 +233,12 @@ const EquipmentCard: React.FC = ({ item, onClick }) => { ); }; -// ── Category Management Dialog ─────────────────────────────────────────────── - -interface CategoryDialogProps { - open: boolean; - onClose: () => void; - categories: AusruestungKategorie[]; - onRefresh: () => void; -} - -const CategoryManagementDialog: React.FC = ({ open, onClose, categories, onRefresh }) => { - const { showSuccess, showError } = useNotification(); - const [editingId, setEditingId] = useState(null); - const [editName, setEditName] = useState(''); - const [editKurzname, setEditKurzname] = useState(''); - const [editMotor, setEditMotor] = useState(false); - const [newName, setNewName] = useState(''); - const [newKurzname, setNewKurzname] = useState(''); - const [newMotor, setNewMotor] = useState(false); - const [saving, setSaving] = useState(false); - - const startEdit = (cat: AusruestungKategorie) => { - setEditingId(cat.id); - setEditName(cat.name); - setEditKurzname(cat.kurzname); - setEditMotor(cat.motorisiert); - }; - - const cancelEdit = () => { - setEditingId(null); - setEditName(''); - setEditKurzname(''); - setEditMotor(false); - }; - - const saveEdit = async () => { - if (!editingId || !editName.trim() || !editKurzname.trim()) return; - setSaving(true); - try { - await equipmentApi.updateCategory(editingId, { name: editName.trim(), kurzname: editKurzname.trim(), motorisiert: editMotor }); - showSuccess('Kategorie aktualisiert'); - cancelEdit(); - onRefresh(); - } catch { - showError('Kategorie konnte nicht aktualisiert werden'); - } finally { - setSaving(false); - } - }; - - const handleCreate = async () => { - if (!newName.trim() || !newKurzname.trim()) return; - setSaving(true); - try { - await equipmentApi.createCategory({ name: newName.trim(), kurzname: newKurzname.trim(), motorisiert: newMotor }); - showSuccess('Kategorie erstellt'); - setNewName(''); - setNewKurzname(''); - setNewMotor(false); - onRefresh(); - } catch { - showError('Kategorie konnte nicht erstellt werden'); - } finally { - setSaving(false); - } - }; - - const handleDelete = async (id: string) => { - setSaving(true); - try { - await equipmentApi.deleteCategory(id); - showSuccess('Kategorie gelöscht'); - onRefresh(); - } catch (err: any) { - const msg = err?.response?.data?.message || 'Kategorie konnte nicht gelöscht werden'; - showError(msg); - } finally { - setSaving(false); - } - }; - - return ( - - - Kategorien verwalten - - - - - - - - Name - Kurzname - Motorisiert - Aktionen - - - - {categories.map((cat) => ( - - {editingId === cat.id ? ( - <> - - setEditName(e.target.value)} fullWidth /> - - - setEditKurzname(e.target.value)} fullWidth /> - - - setEditMotor(e.target.checked)} size="small" /> - - - - - - - - - - - ) : ( - <> - {cat.name} - {cat.kurzname} - {cat.motorisiert ? 'Ja' : 'Nein'} - - startEdit(cat)} disabled={saving}> - - - handleDelete(cat.id)} disabled={saving} color="error"> - - - - - )} - - ))} - {/* New category row */} - - - setNewName(e.target.value)} fullWidth /> - - - setNewKurzname(e.target.value)} fullWidth /> - - - setNewMotor(e.target.checked)} size="small" /> - - - - - - -
-
-
- - - -
- ); -}; - // ── Main Page ───────────────────────────────────────────────────────────────── function Ausruestung() { const navigate = useNavigate(); const { canManageEquipment, hasPermission } = usePermissions(); - const canManageCategories = hasPermission('ausruestung:manage_categories'); - - // Category dialog state - const [categoryDialogOpen, setCategoryDialogOpen] = useState(false); + const canManageTypes = hasPermission('ausruestung:manage_types'); // Data state const [equipment, setEquipment] = useState([]); @@ -424,9 +247,16 @@ function Ausruestung() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Equipment types for filter + const { data: typen = [] } = useQuery({ + queryKey: ['ausruestungTypen'], + queryFn: ausruestungTypenApi.getAll, + }); + // Filter state const [search, setSearch] = useState(''); const [selectedCategory, setSelectedCategory] = useState(''); + const [selectedTyp, setSelectedTyp] = useState(''); const [selectedStatus, setSelectedStatus] = useState(''); const [nurWichtige, setNurWichtige] = useState(false); const [pruefungFaellig, setPruefungFaellig] = useState(false); @@ -471,6 +301,14 @@ function Ausruestung() { return false; } + // Type filter + if (selectedTyp) { + const typId = parseInt(selectedTyp, 10); + if (!item.typen?.some((t) => t.id === typId)) { + return false; + } + } + // Status filter if (selectedStatus && item.status !== selectedStatus) { return false; @@ -490,7 +328,7 @@ function Ausruestung() { return true; }); - }, [equipment, search, selectedCategory, selectedStatus, nurWichtige, pruefungFaellig]); + }, [equipment, search, selectedCategory, selectedTyp, selectedStatus, nurWichtige, pruefungFaellig]); const hasOverdue = equipment.some( (item) => item.pruefung_tage_bis_faelligkeit !== null && item.pruefung_tage_bis_faelligkeit < 0 @@ -506,9 +344,9 @@ function Ausruestung() { Ausrüstungsverwaltung - {canManageCategories && ( - - setCategoryDialogOpen(true)} size="small"> + {canManageTypes && ( + + navigate('/ausruestung/einstellungen')} size="small"> @@ -576,6 +414,22 @@ function Ausruestung() { + + Typ + + + Status setForm((f) => ({ ...f, fahrzeug_typ_id: e.target.value ? Number(e.target.value) : undefined }))}> - Alle (global) - {fahrzeugTypen.map((t) => {t.name})} - + + {/* Assignment type */} + + Zuordnung + handleAssignmentTypeChange(e.target.value as AssignmentType)} + > + } label="Global" /> + } label="Fahrzeugtyp" /> + } label="Fahrzeug" /> + } label="Ausrüstungstyp" /> + } label="Ausrüstung" /> + + + {/* Assignment picker based on type */} + {assignmentType === 'fahrzeug_typ' && ( + + Fahrzeugtyp + + + )} + {assignmentType === 'fahrzeug' && ( + v.bezeichnung ?? v.kurzname ?? String(v.id)} + value={vehiclesList.find((v) => v.id === form.fahrzeug_id) ?? null} + onChange={(_e, v) => setForm((f) => ({ ...f, fahrzeug_id: v?.id }))} + renderInput={(params) => } + /> + )} + {assignmentType === 'ausruestung_typ' && ( + t.name} + value={ausruestungTypen.find((t: AusruestungTyp) => t.id === form.ausruestung_typ_id) ?? null} + onChange={(_e, t: AusruestungTyp | null) => setForm((f) => ({ ...f, ausruestung_typ_id: t?.id }))} + renderInput={(params) => } + /> + )} + {assignmentType === 'ausruestung' && ( + eq.bezeichnung ?? String(eq.id)} + value={equipmentList.find((eq) => eq.id === form.ausruestung_id) ?? null} + onChange={(_e, eq) => setForm((f) => ({ ...f, ausruestung_id: eq?.id }))} + renderInput={(params) => } + /> + )} + Intervall - - Fahrzeug - setTargetFilter(e.target.value)}> Alle - {uniqueVehicles.map((v) => {v})} + {uniqueTargets.map((v) => {v})}
@@ -616,7 +712,7 @@ function HistorieTab({ executions, loading, navigate }: HistorieTabProps) { - Fahrzeug + Fahrzeug / Ausrüstung Vorlage Datum Status @@ -630,7 +726,7 @@ function HistorieTab({ executions, loading, navigate }: HistorieTabProps) { ) : ( filtered.map((e) => ( navigate(`/checklisten/ausfuehrung/${e.id}`)}> - {e.fahrzeug_name ?? '–'} + {e.fahrzeug_name || e.ausruestung_name || '–'} {e.vorlage_name ?? '–'} {formatDate(e.ausgefuehrt_am ?? e.created_at)} diff --git a/frontend/src/pages/FahrzeugDetail.tsx b/frontend/src/pages/FahrzeugDetail.tsx index 9dd7449..8e73086 100644 --- a/frontend/src/pages/FahrzeugDetail.tsx +++ b/frontend/src/pages/FahrzeugDetail.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState, useCallback } from 'react'; import { Alert, + Autocomplete, Box, Button, Chip, @@ -60,6 +61,8 @@ import { vehiclesApi } from '../services/vehicles'; import GermanDateField from '../components/shared/GermanDateField'; import { fromGermanDate } from '../utils/dateInput'; import { equipmentApi } from '../services/equipment'; +import { fahrzeugTypenApi } from '../services/fahrzeugTypen'; +import type { FahrzeugTyp } from '../types/checklist.types'; import { FahrzeugDetail as FahrzeugDetailType, FahrzeugWartungslog, @@ -187,9 +190,10 @@ interface UebersichtTabProps { vehicle: FahrzeugDetailType; onStatusUpdated: () => void; canChangeStatus: boolean; + canEdit: boolean; } -const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, canChangeStatus }) => { +const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, canChangeStatus, canEdit }) => { const [statusDialogOpen, setStatusDialogOpen] = useState(false); const [newStatus, setNewStatus] = useState(vehicle.status); const [bemerkung, setBemerkung] = useState(vehicle.status_bemerkung ?? ''); @@ -203,6 +207,43 @@ const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, const [saveError, setSaveError] = useState(null); const [overlappingBookings, setOverlappingBookings] = useState([]); + // ── Fahrzeugtypen ── + const [allTypes, setAllTypes] = useState([]); + const [vehicleTypes, setVehicleTypes] = useState([]); + const [typesLoading, setTypesLoading] = useState(true); + const [editingTypes, setEditingTypes] = useState(false); + const [selectedTypes, setSelectedTypes] = useState([]); + const [typesSaving, setTypesSaving] = useState(false); + + useEffect(() => { + let cancelled = false; + Promise.all([ + fahrzeugTypenApi.getAll(), + fahrzeugTypenApi.getTypesForVehicle(vehicle.id), + ]) + .then(([all, assigned]) => { + if (cancelled) return; + setAllTypes(all); + setVehicleTypes(assigned); + }) + .catch(() => {}) + .finally(() => { if (!cancelled) setTypesLoading(false); }); + return () => { cancelled = true; }; + }, [vehicle.id]); + + const handleSaveTypes = async () => { + try { + setTypesSaving(true); + await fahrzeugTypenApi.setTypesForVehicle(vehicle.id, selectedTypes.map((t) => t.id)); + setVehicleTypes(selectedTypes); + setEditingTypes(false); + } catch { + // silent — keep dialog open + } finally { + setTypesSaving(false); + } + }; + const isAusserDienst = (s: FahrzeugStatus) => s === FahrzeugStatus.AusserDienstWartung || s === FahrzeugStatus.AusserDienstSchaden; @@ -342,6 +383,50 @@ const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, ))} + {/* Fahrzeugtypen */} + + Fahrzeugtypen + + {typesLoading ? ( + + ) : editingTypes ? ( + + o.name} + value={selectedTypes} + onChange={(_e, val) => setSelectedTypes(val)} + isOptionEqualToValue={(a, b) => a.id === b.id} + renderInput={(params) => } + sx={{ minWidth: 300, flexGrow: 1 }} + /> + + + + ) : ( + + {vehicleTypes.length === 0 ? ( + Keine Typen zugewiesen + ) : ( + vehicleTypes.map((t) => ( + + )) + )} + {canEdit && ( + { setSelectedTypes(vehicleTypes); setEditingTypes(true); }} + aria-label="Fahrzeugtypen bearbeiten" + > + + + )} + + )} + {/* Inspection deadline quick view */} Prüf- und Wartungsfristen @@ -1047,6 +1132,7 @@ function FahrzeugDetail() { vehicle={vehicle} onStatusUpdated={fetchVehicle} canChangeStatus={canChangeStatus} + canEdit={hasPermission('fahrzeuge:edit')} /> diff --git a/frontend/src/pages/FahrzeugEinstellungen.tsx b/frontend/src/pages/FahrzeugEinstellungen.tsx new file mode 100644 index 0000000..4e672ae --- /dev/null +++ b/frontend/src/pages/FahrzeugEinstellungen.tsx @@ -0,0 +1,235 @@ +import { useState } from 'react'; +import { + Alert, + Box, + Button, + CircularProgress, + Container, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material'; +import { + Add as AddIcon, + Delete as DeleteIcon, + Edit as EditIcon, + Settings, +} from '@mui/icons-material'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { usePermissionContext } from '../contexts/PermissionContext'; +import { useNotification } from '../contexts/NotificationContext'; +import { fahrzeugTypenApi } from '../services/fahrzeugTypen'; +import type { FahrzeugTyp } from '../types/checklist.types'; + +export default function FahrzeugEinstellungen() { + const queryClient = useQueryClient(); + const { hasPermission } = usePermissionContext(); + const { showSuccess, showError } = useNotification(); + + const canEdit = hasPermission('fahrzeuge:edit'); + + const { data: fahrzeugTypen = [], isLoading } = useQuery({ + queryKey: ['fahrzeug-typen'], + queryFn: fahrzeugTypenApi.getAll, + }); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState({ name: '', beschreibung: '', icon: '' }); + const [deleteError, setDeleteError] = useState(null); + + const createMutation = useMutation({ + mutationFn: (data: Partial) => fahrzeugTypenApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] }); + setDialogOpen(false); + showSuccess('Fahrzeugtyp erstellt'); + }, + onError: () => showError('Fehler beim Erstellen'), + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: Partial }) => + fahrzeugTypenApi.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] }); + setDialogOpen(false); + showSuccess('Fahrzeugtyp aktualisiert'); + }, + onError: () => showError('Fehler beim Aktualisieren'), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => fahrzeugTypenApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] }); + setDeleteError(null); + showSuccess('Fahrzeugtyp gelöscht'); + }, + onError: (err: any) => { + const msg = err?.response?.data?.message || 'Fehler beim Löschen — Typ ist möglicherweise noch in Verwendung.'; + setDeleteError(msg); + }, + }); + + const openCreate = () => { + setEditing(null); + setForm({ name: '', beschreibung: '', icon: '' }); + setDialogOpen(true); + }; + + const openEdit = (t: FahrzeugTyp) => { + setEditing(t); + setForm({ name: t.name, beschreibung: t.beschreibung ?? '', icon: t.icon ?? '' }); + setDialogOpen(true); + }; + + const handleSubmit = () => { + if (!form.name.trim()) return; + if (editing) { + updateMutation.mutate({ id: editing.id, data: form }); + } else { + createMutation.mutate(form); + } + }; + + const isSaving = createMutation.isPending || updateMutation.isPending; + + if (!canEdit) { + return ( + + + Keine Berechtigung für diese Seite. + + + ); + } + + return ( + + + + + + Fahrzeug-Einstellungen + + + + + Fahrzeugtypen + + + {deleteError && ( + setDeleteError(null)}> + {deleteError} + + )} + + {isLoading ? ( + + + + ) : ( + <> + + + + + +
+ + + Name + Beschreibung + Icon + Aktionen + + + + {fahrzeugTypen.length === 0 ? ( + + + Keine Fahrzeugtypen vorhanden + + + ) : ( + fahrzeugTypen.map((t) => ( + + {t.name} + {t.beschreibung ?? '–'} + {t.icon ?? '–'} + + openEdit(t)}> + + + deleteMutation.mutate(t.id)} + > + + + + + )) + )} + +
+ + + )} + + setDialogOpen(false)} maxWidth="sm" fullWidth> + + {editing ? 'Fahrzeugtyp bearbeiten' : 'Neuer Fahrzeugtyp'} + + + setForm((f) => ({ ...f, name: e.target.value }))} + /> + setForm((f) => ({ ...f, beschreibung: e.target.value }))} + /> + setForm((f) => ({ ...f, icon: e.target.value }))} + placeholder="z.B. fire_truck" + /> + + + + + + + + + ); +} diff --git a/frontend/src/services/ausruestungTypen.ts b/frontend/src/services/ausruestungTypen.ts new file mode 100644 index 0000000..0488e5a --- /dev/null +++ b/frontend/src/services/ausruestungTypen.ts @@ -0,0 +1,37 @@ +import { api } from './api'; + +export interface AusruestungTyp { + id: number; + name: string; + beschreibung?: string; + icon?: string; +} + +export const ausruestungTypenApi = { + getAll: async (): Promise => { + const r = await api.get('/api/ausruestung-typen'); + return r.data.data ?? r.data; + }, + getById: async (id: number): Promise => { + const r = await api.get(`/api/ausruestung-typen/${id}`); + return r.data.data ?? r.data; + }, + create: async (data: { name: string; beschreibung?: string; icon?: string }): Promise => { + const r = await api.post('/api/ausruestung-typen', data); + return r.data.data ?? r.data; + }, + update: async (id: number, data: Partial<{ name: string; beschreibung: string; icon: string }>): Promise => { + const r = await api.patch(`/api/ausruestung-typen/${id}`, data); + return r.data.data ?? r.data; + }, + delete: async (id: number): Promise => { + await api.delete(`/api/ausruestung-typen/${id}`); + }, + getTypesForEquipment: async (ausruestungId: string): Promise => { + const r = await api.get(`/api/ausruestung-typen/equipment/${ausruestungId}`); + return r.data.data ?? r.data; + }, + setTypesForEquipment: async (ausruestungId: string, typIds: number[]): Promise => { + await api.put(`/api/ausruestung-typen/equipment/${ausruestungId}`, { typIds }); + }, +}; diff --git a/frontend/src/services/checklisten.ts b/frontend/src/services/checklisten.ts index 3ddafa3..b0bafe5 100644 --- a/frontend/src/services/checklisten.ts +++ b/frontend/src/services/checklisten.ts @@ -5,6 +5,7 @@ import type { FahrzeugChecklistItem, ChecklistAusfuehrung, ChecklistFaelligkeit, + ChecklistOverviewResponse, ChecklistVorlageFilter, ChecklistAusfuehrungFilter, CreateVorlagePayload, @@ -17,6 +18,12 @@ import type { } from '../types/checklist.types'; export const checklistenApi = { + // ── Overview ── + getOverview: async (): Promise => { + const r = await api.get('/api/checklisten/overview'); + return r.data.data ?? r.data; + }, + // ── Vorlagen (Templates) ── getVorlagen: async (filter?: ChecklistVorlageFilter): Promise => { const params = new URLSearchParams(); @@ -86,6 +93,31 @@ export const checklistenApi = { await api.delete(`/api/checklisten/fahrzeug-items/${id}`); }, + // ── Equipment-specific Items ── + getTemplatesForEquipment: async (ausruestungId: string): Promise => { + const r = await api.get(`/api/checklisten/equipment/${ausruestungId}/vorlagen`); + return r.data.data; + }, + + getEquipmentItems: async (ausruestungId: string): Promise => { + const r = await api.get(`/api/checklisten/equipment/${ausruestungId}/items`); + return r.data.data; + }, + + addEquipmentItem: async (ausruestungId: string, data: CreateFahrzeugItemPayload): Promise => { + const r = await api.post(`/api/checklisten/equipment/${ausruestungId}/items`, data); + return r.data.data; + }, + + updateEquipmentItem: async (ausruestungId: string, itemId: number, data: UpdateFahrzeugItemPayload): Promise => { + const r = await api.patch(`/api/checklisten/equipment/${ausruestungId}/items/${itemId}`, data); + return r.data.data; + }, + + deleteEquipmentItem: async (ausruestungId: string, itemId: number): Promise => { + await api.delete(`/api/checklisten/equipment/${ausruestungId}/items/${itemId}`); + }, + // ── Checklists for a Vehicle ── getChecklistenForVehicle: async (fahrzeugId: string): Promise => { const r = await api.get(`/api/checklisten/fahrzeug/${fahrzeugId}/checklisten`); @@ -93,8 +125,12 @@ export const checklistenApi = { }, // ── Executions ── - startExecution: async (fahrzeugId: string, vorlageId: number): Promise => { - const r = await api.post('/api/checklisten/ausfuehrungen', { fahrzeug_id: fahrzeugId, vorlage_id: vorlageId }); + startExecution: async (vorlageId: number, opts: { fahrzeugId?: string; ausruestungId?: string }): Promise => { + const r = await api.post('/api/checklisten/ausfuehrungen', { + vorlage_id: vorlageId, + fahrzeugId: opts.fahrzeugId, + ausruestungId: opts.ausruestungId, + }); return r.data.data; }, diff --git a/frontend/src/services/fahrzeugTypen.ts b/frontend/src/services/fahrzeugTypen.ts index 76df5db..61e62db 100644 --- a/frontend/src/services/fahrzeugTypen.ts +++ b/frontend/src/services/fahrzeugTypen.ts @@ -25,4 +25,13 @@ export const fahrzeugTypenApi = { delete: async (id: number): Promise => { await api.delete(`/api/fahrzeug-typen/${id}`); }, + + getTypesForVehicle: async (fahrzeugId: string): Promise => { + const r = await api.get(`/api/fahrzeug-typen/vehicle/${fahrzeugId}`); + return r.data.data ?? r.data; + }, + + setTypesForVehicle: async (fahrzeugId: string, typIds: number[]): Promise => { + await api.put(`/api/fahrzeug-typen/vehicle/${fahrzeugId}`, { typIds }); + }, }; diff --git a/frontend/src/types/checklist.types.ts b/frontend/src/types/checklist.types.ts index 5b91618..3cdc973 100644 --- a/frontend/src/types/checklist.types.ts +++ b/frontend/src/types/checklist.types.ts @@ -15,11 +15,24 @@ export interface ChecklistVorlageItem { sort_order: number; } +export interface AusruestungTyp { + id: number; + name: string; + beschreibung?: string; + icon?: string; +} + export interface ChecklistVorlage { id: number; name: string; fahrzeug_typ_id?: number; fahrzeug_typ?: FahrzeugTyp; + fahrzeug_id?: string; + fahrzeug_name?: string; + ausruestung_id?: string; + ausruestung_name?: string; + ausruestung_typ_id?: number; + ausruestung_typ?: string; intervall?: 'weekly' | 'monthly' | 'yearly' | 'custom'; intervall_tage?: number; beschreibung?: string; @@ -52,8 +65,10 @@ export interface ChecklistAusfuehrungItem { export interface ChecklistAusfuehrung { id: string; - fahrzeug_id: string; + fahrzeug_id?: string; fahrzeug_name?: string; + ausruestung_id?: string; + ausruestung_name?: string; vorlage_id?: number; vorlage_name?: string; status: 'offen' | 'abgeschlossen' | 'unvollstaendig' | 'freigegeben'; @@ -69,8 +84,10 @@ export interface ChecklistAusfuehrung { } export interface ChecklistFaelligkeit { - fahrzeug_id: string; - fahrzeug_name: string; + fahrzeug_id?: string; + fahrzeug_name?: string; + ausruestung_id?: string; + ausruestung_name?: string; vorlage_id: number; vorlage_name: string; naechste_faellig_am: string; @@ -107,6 +124,9 @@ export interface ChecklistAusfuehrungFilter { export interface CreateVorlagePayload { name: string; fahrzeug_typ_id?: number; + fahrzeug_id?: string; + ausruestung_typ_id?: number; + ausruestung_id?: string; intervall?: 'weekly' | 'monthly' | 'yearly' | 'custom'; intervall_tage?: number; beschreibung?: string; @@ -116,6 +136,9 @@ export interface CreateVorlagePayload { export interface UpdateVorlagePayload { name?: string; fahrzeug_typ_id?: number | null; + fahrzeug_id?: string | null; + ausruestung_typ_id?: number | null; + ausruestung_id?: string | null; intervall?: 'weekly' | 'monthly' | 'yearly' | 'custom' | null; intervall_tage?: number | null; beschreibung?: string | null; @@ -160,3 +183,21 @@ export interface ChecklistWidgetSummary { overdue: ChecklistFaelligkeit[]; dueSoon: ChecklistFaelligkeit[]; } + +export interface ChecklistOverviewChecklist { + vorlage_id: number; + vorlage_name: string; + next_due?: string; +} + +export interface ChecklistOverviewItem { + id: string; + name: string; + kurzname?: string; + checklists: ChecklistOverviewChecklist[]; +} + +export interface ChecklistOverviewResponse { + vehicles: ChecklistOverviewItem[]; + equipment: ChecklistOverviewItem[]; +} diff --git a/frontend/src/types/equipment.types.ts b/frontend/src/types/equipment.types.ts index b556f97..e368eb8 100644 --- a/frontend/src/types/equipment.types.ts +++ b/frontend/src/types/equipment.types.ts @@ -28,6 +28,15 @@ export interface AusruestungKategorie { motorisiert: boolean; } +// ── Equipment Type (many-to-many) ─────────────────────────────────────────── + +export interface AusruestungTyp { + id: number; + name: string; + beschreibung?: string; + icon?: string; +} + // ── API Response Shapes ────────────────────────────────────────────────────── export interface AusruestungListItem { @@ -52,6 +61,7 @@ export interface AusruestungListItem { pruefung_tage_bis_faelligkeit: number | null; created_at: string; updated_at: string; + typen?: AusruestungTyp[]; } export interface AusruestungDetail extends AusruestungListItem {