From 0c2ea829aa6ed3b377ce5c975ef8cefe58748a07 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Sat, 28 Mar 2026 15:19:41 +0100 Subject: [PATCH] feat: add issue kanban/attachments/deadlines, dashboard widget DnD, and checklisten system --- backend/src/app.ts | 4 + .../src/controllers/checklist.controller.ts | 383 +++++++++++ .../src/controllers/fahrzeugTyp.controller.ts | 126 ++++ backend/src/controllers/issue.controller.ts | 62 ++ .../066_issue_deadlines_and_attachments.sql | 27 + .../migrations/067_fahrzeug_typen.sql | 46 ++ .../database/migrations/068_checklisten.sql | 113 +++ .../069_checklisten_permissions.sql | 78 +++ backend/src/jobs/checklist-reminder.job.ts | 85 +++ backend/src/jobs/issue-reminder.job.ts | 71 ++ backend/src/middleware/upload.ts | 40 +- backend/src/routes/checklist.routes.ts | 163 +++++ backend/src/routes/fahrzeugTyp.routes.ts | 58 ++ backend/src/routes/issue.routes.ts | 21 + backend/src/server.ts | 10 + backend/src/services/checklist.service.ts | 650 ++++++++++++++++++ backend/src/services/fahrzeugTyp.service.ts | 142 ++++ backend/src/services/issue.service.ts | 104 ++- backend/src/services/vehicle.service.ts | 10 + frontend/package-lock.json | 62 ++ frontend/package.json | 3 + frontend/src/App.tsx | 18 + .../components/admin/PermissionMatrixTab.tsx | 6 + .../components/dashboard/ChecklistWidget.tsx | 92 +++ .../components/dashboard/SortableWidget.tsx | 55 ++ frontend/src/components/dashboard/index.ts | 2 + .../fahrzeuge/FahrzeugChecklistTab.tsx | 252 +++++++ .../src/components/issues/KanbanBoard.tsx | 328 +++++++++ frontend/src/components/shared/Sidebar.tsx | 7 + frontend/src/constants/widgets.ts | 1 + frontend/src/pages/ChecklistAusfuehrung.tsx | 314 +++++++++ frontend/src/pages/Checklisten.tsx | 649 +++++++++++++++++ frontend/src/pages/Dashboard.tsx | 427 +++++++----- frontend/src/pages/FahrzeugDetail.tsx | 10 + frontend/src/pages/IssueDetail.tsx | 129 +++- frontend/src/pages/IssueNeu.tsx | 10 + frontend/src/pages/Issues.tsx | 94 ++- frontend/src/services/checklisten.ts | 131 ++++ frontend/src/services/fahrzeugTypen.ts | 28 + frontend/src/services/issues.ts | 18 +- frontend/src/types/checklist.types.ts | 162 +++++ frontend/src/types/issue.types.ts | 14 + 42 files changed, 4804 insertions(+), 201 deletions(-) create mode 100644 backend/src/controllers/checklist.controller.ts create mode 100644 backend/src/controllers/fahrzeugTyp.controller.ts create mode 100644 backend/src/database/migrations/066_issue_deadlines_and_attachments.sql create mode 100644 backend/src/database/migrations/067_fahrzeug_typen.sql create mode 100644 backend/src/database/migrations/068_checklisten.sql create mode 100644 backend/src/database/migrations/069_checklisten_permissions.sql create mode 100644 backend/src/jobs/checklist-reminder.job.ts create mode 100644 backend/src/jobs/issue-reminder.job.ts create mode 100644 backend/src/routes/checklist.routes.ts create mode 100644 backend/src/routes/fahrzeugTyp.routes.ts create mode 100644 backend/src/services/checklist.service.ts create mode 100644 backend/src/services/fahrzeugTyp.service.ts create mode 100644 frontend/src/components/dashboard/ChecklistWidget.tsx create mode 100644 frontend/src/components/dashboard/SortableWidget.tsx create mode 100644 frontend/src/components/fahrzeuge/FahrzeugChecklistTab.tsx create mode 100644 frontend/src/components/issues/KanbanBoard.tsx create mode 100644 frontend/src/pages/ChecklistAusfuehrung.tsx create mode 100644 frontend/src/pages/Checklisten.tsx create mode 100644 frontend/src/services/checklisten.ts create mode 100644 frontend/src/services/fahrzeugTypen.ts create mode 100644 frontend/src/types/checklist.types.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index ba4c39b..145edf7 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -104,6 +104,8 @@ import permissionRoutes from './routes/permission.routes'; import ausruestungsanfrageRoutes from './routes/ausruestungsanfrage.routes'; import issueRoutes from './routes/issue.routes'; import buchungskategorieRoutes from './routes/buchungskategorie.routes'; +import checklistRoutes from './routes/checklist.routes'; +import fahrzeugTypRoutes from './routes/fahrzeugTyp.routes'; app.use('/api/auth', authRoutes); app.use('/api/user', userRoutes); @@ -130,6 +132,8 @@ app.use('/api/permissions', permissionRoutes); app.use('/api/ausruestungsanfragen', ausruestungsanfrageRoutes); app.use('/api/issues', issueRoutes); app.use('/api/buchungskategorien', buchungskategorieRoutes); +app.use('/api/checklisten', checklistRoutes); +app.use('/api/fahrzeug-typen', fahrzeugTypRoutes); // 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/checklist.controller.ts b/backend/src/controllers/checklist.controller.ts new file mode 100644 index 0000000..396441b --- /dev/null +++ b/backend/src/controllers/checklist.controller.ts @@ -0,0 +1,383 @@ +import { Request, Response } from 'express'; +import checklistService from '../services/checklist.service'; +import logger from '../utils/logger'; + +const param = (req: Request, key: string): string => req.params[key] as string; + +class ChecklistController { + // --- Vorlagen (Templates) --- + + async getVorlagen(req: Request, res: Response): Promise { + try { + const filter: { fahrzeug_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.aktiv !== undefined) { + filter.aktiv = req.query.aktiv === 'true'; + } + const vorlagen = await checklistService.getVorlagen(filter); + res.status(200).json({ success: true, data: vorlagen }); + } catch (error) { + logger.error('ChecklistController.getVorlagen error', { error }); + res.status(500).json({ success: false, message: 'Vorlagen konnten nicht geladen werden' }); + } + } + + async getVorlageById(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 vorlage = await checklistService.getVorlageById(id); + if (!vorlage) { + res.status(404).json({ success: false, message: 'Vorlage nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: vorlage }); + } catch (error) { + logger.error('ChecklistController.getVorlageById error', { error }); + res.status(500).json({ success: false, message: 'Vorlage konnte nicht geladen werden' }); + } + } + + async createVorlage(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 vorlage = await checklistService.createVorlage(req.body); + res.status(201).json({ success: true, data: vorlage }); + } catch (error) { + logger.error('ChecklistController.createVorlage error', { error }); + res.status(500).json({ success: false, message: 'Vorlage konnte nicht erstellt werden' }); + } + } + + async updateVorlage(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 vorlage = await checklistService.updateVorlage(id, req.body); + if (!vorlage) { + res.status(404).json({ success: false, message: 'Vorlage nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: vorlage }); + } catch (error) { + logger.error('ChecklistController.updateVorlage error', { error }); + res.status(500).json({ success: false, message: 'Vorlage konnte nicht aktualisiert werden' }); + } + } + + async deleteVorlage(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 vorlage = await checklistService.deleteVorlage(id); + if (!vorlage) { + res.status(404).json({ success: false, message: 'Vorlage nicht gefunden' }); + return; + } + res.status(200).json({ success: true, message: 'Vorlage gelöscht' }); + } catch (error) { + logger.error('ChecklistController.deleteVorlage error', { error }); + res.status(500).json({ success: false, message: 'Vorlage konnte nicht gelöscht werden' }); + } + } + + // --- Vorlage Items --- + + async getVorlageItems(req: Request, res: Response): Promise { + const vorlageId = parseInt(param(req, 'id'), 10); + if (isNaN(vorlageId)) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + return; + } + try { + const items = await checklistService.getVorlageItems(vorlageId); + res.status(200).json({ success: true, data: items }); + } catch (error) { + logger.error('ChecklistController.getVorlageItems error', { error }); + res.status(500).json({ success: false, message: 'Items konnten nicht geladen werden' }); + } + } + + async addVorlageItem(req: Request, res: Response): Promise { + const vorlageId = parseInt(param(req, 'id'), 10); + if (isNaN(vorlageId)) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + 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.addVorlageItem(vorlageId, req.body); + res.status(201).json({ success: true, data: item }); + } catch (error) { + logger.error('ChecklistController.addVorlageItem error', { error }); + res.status(500).json({ success: false, message: 'Item konnte nicht erstellt werden' }); + } + } + + async updateVorlageItem(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 item = await checklistService.updateVorlageItem(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.updateVorlageItem error', { error }); + res.status(500).json({ success: false, message: 'Item konnte nicht aktualisiert werden' }); + } + } + + async deleteVorlageItem(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 item = await checklistService.deleteVorlageItem(id); + if (!item) { + res.status(404).json({ success: false, message: 'Item nicht gefunden' }); + return; + } + res.status(200).json({ success: true, message: 'Item gelöscht' }); + } catch (error) { + logger.error('ChecklistController.deleteVorlageItem error', { error }); + res.status(500).json({ success: false, message: 'Item konnte nicht gelöscht werden' }); + } + } + + // --- Vehicle-specific items --- + + async getVehicleItems(req: Request, res: Response): Promise { + const fahrzeugId = param(req, 'fahrzeugId'); + if (!fahrzeugId) { + res.status(400).json({ success: false, message: 'Fahrzeug-ID erforderlich' }); + return; + } + try { + const items = await checklistService.getVehicleItems(fahrzeugId); + res.status(200).json({ success: true, data: items }); + } catch (error) { + logger.error('ChecklistController.getVehicleItems error', { error }); + res.status(500).json({ success: false, message: 'Items konnten nicht geladen werden' }); + } + } + + async addVehicleItem(req: Request, res: Response): Promise { + const fahrzeugId = param(req, 'fahrzeugId'); + if (!fahrzeugId) { + res.status(400).json({ success: false, message: 'Fahrzeug-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.addVehicleItem(fahrzeugId, req.body); + res.status(201).json({ success: true, data: item }); + } catch (error) { + logger.error('ChecklistController.addVehicleItem error', { error }); + res.status(500).json({ success: false, message: 'Item konnte nicht erstellt werden' }); + } + } + + async updateVehicleItem(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 item = await checklistService.updateVehicleItem(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.updateVehicleItem error', { error }); + res.status(500).json({ success: false, message: 'Item konnte nicht aktualisiert werden' }); + } + } + + async deleteVehicleItem(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 item = await checklistService.deleteVehicleItem(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.deleteVehicleItem error', { error }); + res.status(500).json({ success: false, message: 'Item konnte nicht deaktiviert werden' }); + } + } + + // --- Templates for vehicle --- + + async getTemplatesForVehicle(req: Request, res: Response): Promise { + const fahrzeugId = param(req, 'fahrzeugId'); + if (!fahrzeugId) { + res.status(400).json({ success: false, message: 'Fahrzeug-ID erforderlich' }); + return; + } + try { + const templates = await checklistService.getTemplatesForVehicle(fahrzeugId); + res.status(200).json({ success: true, data: templates }); + } catch (error) { + logger.error('ChecklistController.getTemplatesForVehicle error', { error }); + res.status(500).json({ success: false, message: 'Checklisten konnten nicht geladen werden' }); + } + } + + // --- Ausführungen (Executions) --- + + 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' }); + return; + } + try { + const execution = await checklistService.startExecution(fahrzeugId, vorlageId, req.user!.id); + res.status(201).json({ success: true, data: execution }); + } catch (error) { + logger.error('ChecklistController.startExecution error', { error }); + res.status(500).json({ success: false, message: 'Ausführung konnte nicht gestartet werden' }); + } + } + + async submitExecution(req: Request, res: Response): Promise { + const id = param(req, 'id'); + if (!id) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + return; + } + const { items, notizen } = req.body; + if (!Array.isArray(items)) { + res.status(400).json({ success: false, message: 'items muss ein Array sein' }); + return; + } + try { + const execution = await checklistService.submitExecution(id, items, notizen ?? null, req.user!.id); + res.status(200).json({ success: true, data: execution }); + } catch (error) { + logger.error('ChecklistController.submitExecution error', { error }); + res.status(500).json({ success: false, message: 'Ausführung konnte nicht abgeschlossen werden' }); + } + } + + async approveExecution(req: Request, res: Response): Promise { + const id = param(req, 'id'); + if (!id) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + return; + } + try { + const execution = await checklistService.approveExecution(id, req.user!.id); + if (!execution) { + res.status(404).json({ success: false, message: 'Ausführung nicht gefunden oder Status ungültig' }); + return; + } + res.status(200).json({ success: true, data: execution }); + } catch (error) { + logger.error('ChecklistController.approveExecution error', { error }); + res.status(500).json({ success: false, message: 'Freigabe konnte nicht erteilt werden' }); + } + } + + async getExecutions(req: Request, res: Response): Promise { + try { + const filter: { fahrzeugId?: string; vorlageId?: number; status?: string } = {}; + if (req.query.fahrzeugId) filter.fahrzeugId = req.query.fahrzeugId 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); + res.status(200).json({ success: true, data: executions }); + } catch (error) { + logger.error('ChecklistController.getExecutions error', { error }); + res.status(500).json({ success: false, message: 'Ausführungen konnten nicht geladen werden' }); + } + } + + async getExecutionById(req: Request, res: Response): Promise { + const id = param(req, 'id'); + if (!id) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + return; + } + try { + const execution = await checklistService.getExecutionById(id); + if (!execution) { + res.status(404).json({ success: false, message: 'Ausführung nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: execution }); + } catch (error) { + logger.error('ChecklistController.getExecutionById error', { error }); + res.status(500).json({ success: false, message: 'Ausführung konnte nicht geladen werden' }); + } + } + + // --- Fälligkeiten --- + + async getOverdueChecklists(_req: Request, res: Response): Promise { + try { + const overdue = await checklistService.getOverdueChecklists(); + res.status(200).json({ success: true, data: overdue }); + } catch (error) { + logger.error('ChecklistController.getOverdueChecklists error', { error }); + res.status(500).json({ success: false, message: 'Überfällige Checklisten konnten nicht geladen werden' }); + } + } + + async getDueChecklists(req: Request, res: Response): Promise { + const fahrzeugId = param(req, 'fahrzeugId'); + if (!fahrzeugId) { + res.status(400).json({ success: false, message: 'Fahrzeug-ID erforderlich' }); + return; + } + try { + const due = await checklistService.getDueChecklists(fahrzeugId); + res.status(200).json({ success: true, data: due }); + } catch (error) { + logger.error('ChecklistController.getDueChecklists error', { error }); + res.status(500).json({ success: false, message: 'Fälligkeiten konnten nicht geladen werden' }); + } + } +} + +export default new ChecklistController(); diff --git a/backend/src/controllers/fahrzeugTyp.controller.ts b/backend/src/controllers/fahrzeugTyp.controller.ts new file mode 100644 index 0000000..13cec15 --- /dev/null +++ b/backend/src/controllers/fahrzeugTyp.controller.ts @@ -0,0 +1,126 @@ +import { Request, Response } from 'express'; +import fahrzeugTypService from '../services/fahrzeugTyp.service'; +import logger from '../utils/logger'; + +const param = (req: Request, key: string): string => req.params[key] as string; + +class FahrzeugTypController { + async getAll(_req: Request, res: Response): Promise { + try { + const types = await fahrzeugTypService.getAll(); + res.status(200).json({ success: true, data: types }); + } catch (error) { + logger.error('FahrzeugTypController.getAll error', { error }); + res.status(500).json({ success: false, message: 'Fahrzeug-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 fahrzeugTypService.getById(id); + if (!type) { + res.status(404).json({ success: false, message: 'Fahrzeug-Typ nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: type }); + } catch (error) { + logger.error('FahrzeugTypController.getById error', { error }); + res.status(500).json({ success: false, message: 'Fahrzeug-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 fahrzeugTypService.create(req.body); + res.status(201).json({ success: true, data: type }); + } catch (error) { + logger.error('FahrzeugTypController.create error', { error }); + res.status(500).json({ success: false, message: 'Fahrzeug-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 fahrzeugTypService.update(id, req.body); + if (!type) { + res.status(404).json({ success: false, message: 'Fahrzeug-Typ nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: type }); + } catch (error) { + logger.error('FahrzeugTypController.update error', { error }); + res.status(500).json({ success: false, message: 'Fahrzeug-Typ konnte nicht aktualisiert werden' }); + } + } + + async delete(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 fahrzeugTypService.delete(id); + if (!type) { + res.status(404).json({ success: false, message: 'Fahrzeug-Typ nicht gefunden' }); + return; + } + res.status(200).json({ success: true, message: 'Fahrzeug-Typ gelöscht' }); + } catch (error) { + logger.error('FahrzeugTypController.delete error', { error }); + res.status(500).json({ success: false, message: 'Fahrzeug-Typ konnte nicht gelöscht werden' }); + } + } + + async getTypesForVehicle(req: Request, res: Response): Promise { + const fahrzeugId = param(req, 'fahrzeugId'); + if (!fahrzeugId) { + res.status(400).json({ success: false, message: 'Fahrzeug-ID erforderlich' }); + return; + } + try { + const types = await fahrzeugTypService.getTypesForVehicle(fahrzeugId); + res.status(200).json({ success: true, data: types }); + } catch (error) { + logger.error('FahrzeugTypController.getTypesForVehicle error', { error }); + res.status(500).json({ success: false, message: 'Fahrzeug-Typen konnten nicht geladen werden' }); + } + } + + async setTypesForVehicle(req: Request, res: Response): Promise { + const fahrzeugId = param(req, 'fahrzeugId'); + if (!fahrzeugId) { + res.status(400).json({ success: false, message: 'Fahrzeug-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 fahrzeugTypService.setTypesForVehicle(fahrzeugId, typIds); + res.status(200).json({ success: true, data: types }); + } catch (error) { + logger.error('FahrzeugTypController.setTypesForVehicle error', { error }); + res.status(500).json({ success: false, message: 'Fahrzeug-Typen konnten nicht gesetzt werden' }); + } + } +} + +export default new FahrzeugTypController(); diff --git a/backend/src/controllers/issue.controller.ts b/backend/src/controllers/issue.controller.ts index 631b7f4..fcb390b 100644 --- a/backend/src/controllers/issue.controller.ts +++ b/backend/src/controllers/issue.controller.ts @@ -175,6 +175,7 @@ class IssueController { titel: 'Titel geändert', beschreibung: 'Beschreibung geändert', typ_id: 'Typ geändert', + faellig_am: 'Fälligkeitsdatum geändert', }; for (const [field, label] of Object.entries(fieldLabels)) { if (field in updateData && updateData[field] !== existing[field]) { @@ -503,6 +504,67 @@ class IssueController { res.status(500).json({ success: false, message: 'Priorität konnte nicht deaktiviert werden' }); } } + + // --- File management --- + + async uploadFile(req: Request, res: Response): Promise { + const issueId = parseInt(param(req, 'id'), 10); + if (isNaN(issueId)) { + res.status(400).json({ success: false, message: 'Ungültige Issue-ID' }); + return; + } + const file = req.file as Express.Multer.File | undefined; + if (!file) { + res.status(400).json({ success: false, message: 'Keine Datei hochgeladen' }); + return; + } + try { + const fileRecord = await issueService.addFile(issueId, { + dateiname: file.originalname, + dateipfad: file.path, + dateityp: file.mimetype, + dateigroesse: file.size, + }, req.user!.id); + res.status(201).json({ success: true, data: fileRecord }); + } catch (error) { + logger.error('IssueController.uploadFile error', { error }); + res.status(500).json({ success: false, message: 'Datei konnte nicht hochgeladen werden' }); + } + } + + async getFiles(req: Request, res: Response): Promise { + const issueId = parseInt(param(req, 'id'), 10); + if (isNaN(issueId)) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + return; + } + try { + const files = await issueService.getFiles(issueId); + res.status(200).json({ success: true, data: files }); + } catch (error) { + logger.error('IssueController.getFiles error', { error }); + res.status(500).json({ success: false, message: 'Dateien konnten nicht geladen werden' }); + } + } + + async deleteFile(req: Request, res: Response): Promise { + const fileId = param(req, 'fileId'); + if (!fileId) { + res.status(400).json({ success: false, message: 'Ungültige Datei-ID' }); + return; + } + try { + const result = await issueService.deleteFile(fileId, req.user!.id); + if (!result) { + res.status(404).json({ success: false, message: 'Datei nicht gefunden' }); + return; + } + res.status(200).json({ success: true, message: 'Datei gelöscht' }); + } catch (error) { + logger.error('IssueController.deleteFile error', { error }); + res.status(500).json({ success: false, message: 'Datei konnte nicht gelöscht werden' }); + } + } } export default new IssueController(); diff --git a/backend/src/database/migrations/066_issue_deadlines_and_attachments.sql b/backend/src/database/migrations/066_issue_deadlines_and_attachments.sql new file mode 100644 index 0000000..c894d4b --- /dev/null +++ b/backend/src/database/migrations/066_issue_deadlines_and_attachments.sql @@ -0,0 +1,27 @@ +-- Migration 066: Issue due dates + file attachments +-- Adds faellig_am column to issues and creates issue_dateien table. + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 1. Add due date column to issues +-- ═══════════════════════════════════════════════════════════════════════════ + +ALTER TABLE issues ADD COLUMN IF NOT EXISTS faellig_am TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_issues_faellig_am ON issues(faellig_am) WHERE faellig_am IS NOT NULL; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 2. Issue file attachments +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS issue_dateien ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issue_id INT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + dateiname VARCHAR(500) NOT NULL, + dateipfad VARCHAR(1000) NOT NULL, + dateityp VARCHAR(100), + dateigroesse BIGINT, + hochgeladen_von UUID REFERENCES users(id) ON DELETE SET NULL, + hochgeladen_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_issue_dateien_issue_id ON issue_dateien(issue_id); diff --git a/backend/src/database/migrations/067_fahrzeug_typen.sql b/backend/src/database/migrations/067_fahrzeug_typen.sql new file mode 100644 index 0000000..727923e --- /dev/null +++ b/backend/src/database/migrations/067_fahrzeug_typen.sql @@ -0,0 +1,46 @@ +-- Migration 067: Fahrzeug-Typen (Vehicle Types) +-- Dynamic vehicle type table with many-to-many junction to fahrzeuge. +-- Seeds initial types from existing fahrzeuge.typ_schluessel values. + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 1. Fahrzeug-Typen +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS fahrzeug_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 fahrzeug_fahrzeug_typen ( + fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE, + fahrzeug_typ_id INT NOT NULL REFERENCES fahrzeug_typen(id) ON DELETE CASCADE, + PRIMARY KEY (fahrzeug_id, fahrzeug_typ_id) +); + +CREATE INDEX IF NOT EXISTS idx_fft_fahrzeug_id ON fahrzeug_fahrzeug_typen(fahrzeug_id); +CREATE INDEX IF NOT EXISTS idx_fft_typ_id ON fahrzeug_fahrzeug_typen(fahrzeug_typ_id); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 3. Seed types from existing typ_schluessel values +-- ═══════════════════════════════════════════════════════════════════════════ + +INSERT INTO fahrzeug_typen (name) + SELECT DISTINCT typ_schluessel + FROM fahrzeuge + WHERE typ_schluessel IS NOT NULL AND typ_schluessel != '' +ON CONFLICT (name) DO NOTHING; + +-- Populate junction table from existing assignments +INSERT INTO fahrzeug_fahrzeug_typen (fahrzeug_id, fahrzeug_typ_id) + SELECT f.id, ft.id + FROM fahrzeuge f + JOIN fahrzeug_typen ft ON ft.name = f.typ_schluessel + WHERE f.typ_schluessel IS NOT NULL AND f.typ_schluessel != '' +ON CONFLICT DO NOTHING; diff --git a/backend/src/database/migrations/068_checklisten.sql b/backend/src/database/migrations/068_checklisten.sql new file mode 100644 index 0000000..d4dcb24 --- /dev/null +++ b/backend/src/database/migrations/068_checklisten.sql @@ -0,0 +1,113 @@ +-- Migration 068: Checklisten (Checklist system) +-- Templates, vehicle-specific items, execution records, and due date tracking. +-- Depends on: 067_fahrzeug_typen.sql (fahrzeug_typen table) + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 1. Checklist-Vorlagen (Templates) +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS checklist_vorlagen ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + fahrzeug_typ_id INT REFERENCES fahrzeug_typen(id) ON DELETE SET NULL, + intervall VARCHAR(20) CHECK (intervall IN ('weekly','monthly','yearly','custom')), + intervall_tage INT, + beschreibung TEXT, + aktiv BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 2. Vorlage Items (Template line items) +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS checklist_vorlage_items ( + id SERIAL PRIMARY KEY, + vorlage_id INT NOT NULL REFERENCES checklist_vorlagen(id) ON DELETE CASCADE, + bezeichnung VARCHAR(500) NOT NULL, + beschreibung TEXT, + pflicht BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INT NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_cvi_vorlage_id ON checklist_vorlage_items(vorlage_id); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 3. Fahrzeug-spezifische Checklist Items +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS fahrzeug_checklist_items ( + id SERIAL PRIMARY KEY, + fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(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 +); + +CREATE INDEX IF NOT EXISTS idx_fci_fahrzeug_id ON fahrzeug_checklist_items(fahrzeug_id); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 4. Checklist-Ausführungen (Execution records) +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS checklist_ausfuehrungen ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE, + vorlage_id INT REFERENCES checklist_vorlagen(id) ON DELETE SET NULL, + status VARCHAR(30) NOT NULL DEFAULT 'offen' + CHECK (status IN ('offen','abgeschlossen','unvollstaendig','freigegeben')), + ausgefuehrt_von UUID REFERENCES users(id) ON DELETE SET NULL, + ausgefuehrt_am TIMESTAMPTZ, + freigegeben_von UUID REFERENCES users(id) ON DELETE SET NULL, + freigegeben_am TIMESTAMPTZ, + notizen TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ca_fahrzeug_id ON checklist_ausfuehrungen(fahrzeug_id); +CREATE INDEX IF NOT EXISTS idx_ca_vorlage_id ON checklist_ausfuehrungen(vorlage_id); +CREATE INDEX IF NOT EXISTS idx_ca_status ON checklist_ausfuehrungen(status); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 5. Ausführung Items (Execution line items / answers) +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS checklist_ausfuehrung_items ( + id SERIAL PRIMARY KEY, + ausfuehrung_id UUID NOT NULL REFERENCES checklist_ausfuehrungen(id) ON DELETE CASCADE, + vorlage_item_id INT REFERENCES checklist_vorlage_items(id) ON DELETE SET NULL, + fahrzeug_item_id INT REFERENCES fahrzeug_checklist_items(id) ON DELETE SET NULL, + bezeichnung VARCHAR(500), + ergebnis VARCHAR(20) CHECK (ergebnis IN ('ok','nok','na')), + kommentar TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_cai_ausfuehrung_id ON checklist_ausfuehrung_items(ausfuehrung_id); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 6. Fälligkeiten (Due date tracking per vehicle+template) +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS checklist_faelligkeit ( + fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE, + vorlage_id INT NOT NULL REFERENCES checklist_vorlagen(id) ON DELETE CASCADE, + naechste_faellig_am DATE NOT NULL, + letzte_ausfuehrung_id UUID REFERENCES checklist_ausfuehrungen(id) ON DELETE SET NULL, + PRIMARY KEY (fahrzeug_id, vorlage_id) +); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 7. Auto-update updated_at trigger for checklist_vorlagen +-- ═══════════════════════════════════════════════════════════════════════════ + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_checklist_vorlagen_updated') THEN + CREATE TRIGGER trg_checklist_vorlagen_updated BEFORE UPDATE ON checklist_vorlagen + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + END IF; +END $$; diff --git a/backend/src/database/migrations/069_checklisten_permissions.sql b/backend/src/database/migrations/069_checklisten_permissions.sql new file mode 100644 index 0000000..2f9eb9e --- /dev/null +++ b/backend/src/database/migrations/069_checklisten_permissions.sql @@ -0,0 +1,78 @@ +-- Migration 069: Checklisten permissions +-- Adds checklisten feature group and permissions, seeds group_permissions. + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 1. Feature group +-- ═══════════════════════════════════════════════════════════════════════════ + +INSERT INTO feature_groups (id, label, sort_order) VALUES + ('checklisten', 'Checklisten', 15) +ON CONFLICT (id) DO NOTHING; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 2. Permissions +-- ═══════════════════════════════════════════════════════════════════════════ + +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('checklisten:view', 'checklisten', 'Ansehen', 'Checklisten und Ausführungen einsehen', 1), + ('checklisten:execute', 'checklisten', 'Ausfüllen', 'Checklisten ausfüllen und abschließen', 2), + ('checklisten:approve', 'checklisten', 'Freigeben', 'Checklisten nach Prüfung freigeben', 3), + ('checklisten:manage_templates', 'checklisten', 'Vorlagen verwalten', 'Vorlagen und Fahrzeugtypen erstellen und bearbeiten', 4), + ('checklisten:widget', 'checklisten', 'Widget', 'Checklisten-Widget im Dashboard anzeigen', 5) +ON CONFLICT (id) DO NOTHING; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 3. Seed group permissions +-- ═══════════════════════════════════════════════════════════════════════════ +-- dashboard_admin has hardwired full access (not seeded). + +-- Kommando: all permissions +INSERT INTO group_permissions (authentik_group, permission_id) VALUES + ('dashboard_kommando', 'checklisten:view'), + ('dashboard_kommando', 'checklisten:execute'), + ('dashboard_kommando', 'checklisten:approve'), + ('dashboard_kommando', 'checklisten:manage_templates'), + ('dashboard_kommando', 'checklisten:widget') +ON CONFLICT DO NOTHING; + +-- Fahrmeister: view, execute, approve, widget (vehicle specialist) +INSERT INTO group_permissions (authentik_group, permission_id) VALUES + ('dashboard_fahrmeister', 'checklisten:view'), + ('dashboard_fahrmeister', 'checklisten:execute'), + ('dashboard_fahrmeister', 'checklisten:approve'), + ('dashboard_fahrmeister', 'checklisten:widget') +ON CONFLICT DO NOTHING; + +-- Zeugmeister: view, execute, approve, widget (equipment specialist) +INSERT INTO group_permissions (authentik_group, permission_id) VALUES + ('dashboard_zeugmeister', 'checklisten:view'), + ('dashboard_zeugmeister', 'checklisten:execute'), + ('dashboard_zeugmeister', 'checklisten:approve'), + ('dashboard_zeugmeister', 'checklisten:widget') +ON CONFLICT DO NOTHING; + +-- Chargen: view, execute, widget +INSERT INTO group_permissions (authentik_group, permission_id) VALUES + ('dashboard_chargen', 'checklisten:view'), + ('dashboard_chargen', 'checklisten:execute'), + ('dashboard_chargen', 'checklisten:widget') +ON CONFLICT DO NOTHING; + +-- Moderator: view, widget +INSERT INTO group_permissions (authentik_group, permission_id) VALUES + ('dashboard_moderator', 'checklisten:view'), + ('dashboard_moderator', 'checklisten:widget') +ON CONFLICT DO NOTHING; + +-- Atemschutz: view, execute, widget +INSERT INTO group_permissions (authentik_group, permission_id) VALUES + ('dashboard_atemschutz', 'checklisten:view'), + ('dashboard_atemschutz', 'checklisten:execute'), + ('dashboard_atemschutz', 'checklisten:widget') +ON CONFLICT DO NOTHING; + +-- Mitglied: view, widget +INSERT INTO group_permissions (authentik_group, permission_id) VALUES + ('dashboard_mitglied', 'checklisten:view'), + ('dashboard_mitglied', 'checklisten:widget') +ON CONFLICT DO NOTHING; diff --git a/backend/src/jobs/checklist-reminder.job.ts b/backend/src/jobs/checklist-reminder.job.ts new file mode 100644 index 0000000..db06095 --- /dev/null +++ b/backend/src/jobs/checklist-reminder.job.ts @@ -0,0 +1,85 @@ +import pool from '../config/database'; +import notificationService from '../services/notification.service'; +import logger from '../utils/logger'; + +const INTERVAL_MS = 15 * 60 * 1000; // 15 minutes +let jobInterval: ReturnType | null = null; +let isRunning = false; + +async function runChecklistReminderCheck(): Promise { + if (isRunning) { + logger.warn('ChecklistReminderJob: previous run still in progress — skipping'); + return; + } + isRunning = true; + try { + // Find overdue checklists + const result = await pool.query(` + SELECT cf.fahrzeug_id, cf.vorlage_id, cf.naechste_faellig_am, + f.bezeichnung AS fahrzeug_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 + JOIN checklist_vorlagen v ON v.id = cf.vorlage_id AND v.aktiv = true + WHERE cf.naechste_faellig_am <= CURRENT_DATE + `); + + if (result.rows.length === 0) return; + + // Find users who can execute checklists (Zeugmeister, Fahrmeister, Kommandant groups) + const usersResult = await pool.query(` + SELECT id FROM users + WHERE authentik_groups && ARRAY['dashboard_zeugmeister', 'dashboard_fahrmeister', 'dashboard_kommando', 'dashboard_admin']::text[] + `); + + const targetUserIds = usersResult.rows.map((r: any) => r.id); + if (targetUserIds.length === 0) return; + + for (const row of result.rows) { + const faelligDatum = new Date(row.naechste_faellig_am).toLocaleDateString('de-AT', { + day: '2-digit', month: '2-digit', year: 'numeric', + }); + + // Notify first responsible user (avoid spam by using quell_id dedup) + 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}`, + schwere: 'warnung', + link: `/checklisten`, + quell_id: `${row.fahrzeug_id}_${row.vorlage_id}`, + quell_typ: 'checklist_faellig', + }); + } + } + + logger.info(`ChecklistReminderJob: processed ${result.rows.length} overdue checklists`); + } catch (error) { + logger.error('ChecklistReminderJob: unexpected error', { + error: error instanceof Error ? error.message : String(error), + }); + } finally { + isRunning = false; + } +} + +export function startChecklistReminderJob(): void { + if (jobInterval !== null) { + logger.warn('Checklist reminder job already running — skipping duplicate start'); + return; + } + // Run once after short delay, then repeat + setTimeout(() => runChecklistReminderCheck(), 75 * 1000); + jobInterval = setInterval(() => runChecklistReminderCheck(), INTERVAL_MS); + logger.info('Checklist reminder job scheduled (every 15 minutes)'); +} + +export function stopChecklistReminderJob(): void { + if (jobInterval !== null) { + clearInterval(jobInterval); + jobInterval = null; + } + logger.info('Checklist reminder job stopped'); +} diff --git a/backend/src/jobs/issue-reminder.job.ts b/backend/src/jobs/issue-reminder.job.ts new file mode 100644 index 0000000..8bdd9fe --- /dev/null +++ b/backend/src/jobs/issue-reminder.job.ts @@ -0,0 +1,71 @@ +import pool from '../config/database'; +import notificationService from '../services/notification.service'; +import logger from '../utils/logger'; + +const INTERVAL_MS = 15 * 60 * 1000; // 15 minutes +let jobInterval: ReturnType | null = null; +let isRunning = false; + +async function runIssueReminderCheck(): Promise { + if (isRunning) { + logger.warn('IssueReminderJob: previous run still in progress — skipping'); + return; + } + isRunning = true; + try { + // Find overdue issues that are not in a terminal status + const result = await pool.query(` + SELECT i.id, i.titel, i.faellig_am, i.erstellt_von, i.zugewiesen_an + FROM issues i + LEFT JOIN issue_statuses s ON s.schluessel = i.status + WHERE i.faellig_am < NOW() + AND (s.ist_abschluss = false OR s.ist_abschluss IS NULL) + `); + + for (const row of result.rows) { + // Notify the assignee, or the creator if no assignee + const targetUserId = row.zugewiesen_an || row.erstellt_von; + if (!targetUserId) continue; + + await notificationService.createNotification({ + user_id: targetUserId, + typ: 'issue_ueberfaellig', + titel: 'Issue überfällig', + nachricht: `Issue "${row.titel}" ist überfällig`, + schwere: 'warnung', + link: `/issues/${row.id}`, + quell_id: `issue-ueberfaellig-${row.id}`, + quell_typ: 'issue_ueberfaellig', + }); + } + + if (result.rows.length > 0) { + logger.info(`IssueReminderJob: processed ${result.rows.length} overdue issues`); + } + } catch (error) { + logger.error('IssueReminderJob: unexpected error', { + error: error instanceof Error ? error.message : String(error), + }); + } finally { + isRunning = false; + } +} + +export function startIssueReminderJob(): void { + if (jobInterval !== null) { + logger.warn('Issue reminder job already running — skipping duplicate start'); + return; + } + // Run once after short delay, then repeat + setTimeout(() => runIssueReminderCheck(), 60 * 1000); + jobInterval = setInterval(() => runIssueReminderCheck(), INTERVAL_MS); + logger.info('Issue reminder job scheduled (every 15 minutes)'); +} + +export function stopIssueReminderJob(): void { + if (jobInterval !== null) { + clearInterval(jobInterval); + jobInterval = null; + } + logger.info('Issue reminder job stopped'); +} diff --git a/backend/src/middleware/upload.ts b/backend/src/middleware/upload.ts index 85bf8d3..17c054a 100644 --- a/backend/src/middleware/upload.ts +++ b/backend/src/middleware/upload.ts @@ -94,4 +94,42 @@ const wartungOptions: any = { // eslint-disable-next-line @typescript-eslint/no-explicit-any export const uploadWartung: any = multer(wartungOptions); -export { UPLOAD_DIR, THUMBNAIL_DIR, WARTUNG_DIR }; +// ── Issue uploads ──────────────────────────────────────────────────────────── + +const ISSUE_DIR = path.join(APP_ROOT, 'uploads', 'issues'); +try { + if (!fs.existsSync(ISSUE_DIR)) { + fs.mkdirSync(ISSUE_DIR, { recursive: true }); + } +} catch (err) { + logger.warn(`Could not create issue upload directory`, { err }); +} + +const issueStorage = multer.diskStorage({ + destination(_req: any, _file: any, cb: any) { + cb(null, ISSUE_DIR); + }, + filename(_req: any, file: any, cb: any) { + const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + const ext = path.extname(file.originalname); + cb(null, `${uniqueSuffix}${ext}`); + }, +}); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const issueOptions: any = { + storage: issueStorage, + fileFilter(_req: any, file: any, cb: any) { + if (ALLOWED_TYPES.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error(`Dateityp ${file.mimetype} ist nicht erlaubt.`)); + } + }, + limits: { fileSize: 20 * 1024 * 1024 }, +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const uploadIssue: any = multer(issueOptions); + +export { UPLOAD_DIR, THUMBNAIL_DIR, WARTUNG_DIR, ISSUE_DIR }; diff --git a/backend/src/routes/checklist.routes.ts b/backend/src/routes/checklist.routes.ts new file mode 100644 index 0000000..0ceb49c --- /dev/null +++ b/backend/src/routes/checklist.routes.ts @@ -0,0 +1,163 @@ +import { Router } from 'express'; +import checklistController from '../controllers/checklist.controller'; +import { authenticate } from '../middleware/auth.middleware'; +import { requirePermission } from '../middleware/rbac.middleware'; + +const router = Router(); + +// --- Fälligkeiten (before /:id routes) --- +router.get( + '/faellig', + authenticate, + requirePermission('checklisten:view'), + checklistController.getOverdueChecklists.bind(checklistController) +); + +// --- Vorlagen (Templates) --- +router.get( + '/vorlagen', + authenticate, + requirePermission('checklisten:view'), + checklistController.getVorlagen.bind(checklistController) +); + +router.post( + '/vorlagen', + authenticate, + requirePermission('checklisten:manage_templates'), + checklistController.createVorlage.bind(checklistController) +); + +router.get( + '/vorlagen/:id', + authenticate, + requirePermission('checklisten:view'), + checklistController.getVorlageById.bind(checklistController) +); + +router.put( + '/vorlagen/:id', + authenticate, + requirePermission('checklisten:manage_templates'), + checklistController.updateVorlage.bind(checklistController) +); + +router.delete( + '/vorlagen/:id', + authenticate, + requirePermission('checklisten:manage_templates'), + checklistController.deleteVorlage.bind(checklistController) +); + +// --- Vorlage Items --- +router.get( + '/vorlagen/:id/items', + authenticate, + requirePermission('checklisten:view'), + checklistController.getVorlageItems.bind(checklistController) +); + +router.post( + '/vorlagen/:id/items', + authenticate, + requirePermission('checklisten:manage_templates'), + checklistController.addVorlageItem.bind(checklistController) +); + +// Item-level routes (not nested under vorlage) +router.put( + '/items/:id', + authenticate, + requirePermission('checklisten:manage_templates'), + checklistController.updateVorlageItem.bind(checklistController) +); + +router.delete( + '/items/:id', + authenticate, + requirePermission('checklisten:manage_templates'), + checklistController.deleteVorlageItem.bind(checklistController) +); + +// --- Vehicle-specific items --- +router.get( + '/fahrzeug/:fahrzeugId/items', + authenticate, + requirePermission('checklisten:view'), + checklistController.getVehicleItems.bind(checklistController) +); + +router.post( + '/fahrzeug/:fahrzeugId/items', + authenticate, + requirePermission('checklisten:manage_templates'), + checklistController.addVehicleItem.bind(checklistController) +); + +router.put( + '/fahrzeug-items/:id', + authenticate, + requirePermission('checklisten:manage_templates'), + checklistController.updateVehicleItem.bind(checklistController) +); + +router.delete( + '/fahrzeug-items/:id', + authenticate, + requirePermission('checklisten:manage_templates'), + checklistController.deleteVehicleItem.bind(checklistController) +); + +// --- Applicable checklists for a vehicle --- +router.get( + '/fahrzeug/:fahrzeugId/checklisten', + authenticate, + requirePermission('checklisten:view'), + checklistController.getTemplatesForVehicle.bind(checklistController) +); + +// --- Vehicle due dates --- +router.get( + '/fahrzeug/:fahrzeugId/faellig', + authenticate, + requirePermission('checklisten:view'), + checklistController.getDueChecklists.bind(checklistController) +); + +// --- Ausführungen (Executions) --- +router.get( + '/ausfuehrungen', + authenticate, + requirePermission('checklisten:view'), + checklistController.getExecutions.bind(checklistController) +); + +router.post( + '/ausfuehrungen', + authenticate, + requirePermission('checklisten:execute'), + checklistController.startExecution.bind(checklistController) +); + +router.get( + '/ausfuehrungen/:id', + authenticate, + requirePermission('checklisten:view'), + checklistController.getExecutionById.bind(checklistController) +); + +router.put( + '/ausfuehrungen/:id', + authenticate, + requirePermission('checklisten:execute'), + checklistController.submitExecution.bind(checklistController) +); + +router.post( + '/ausfuehrungen/:id/freigabe', + authenticate, + requirePermission('checklisten:approve'), + checklistController.approveExecution.bind(checklistController) +); + +export default router; diff --git a/backend/src/routes/fahrzeugTyp.routes.ts b/backend/src/routes/fahrzeugTyp.routes.ts new file mode 100644 index 0000000..07ff6ea --- /dev/null +++ b/backend/src/routes/fahrzeugTyp.routes.ts @@ -0,0 +1,58 @@ +import { Router } from 'express'; +import fahrzeugTypController from '../controllers/fahrzeugTyp.controller'; +import { authenticate } from '../middleware/auth.middleware'; +import { requirePermission } from '../middleware/rbac.middleware'; + +const router = Router(); + +// List all vehicle types +router.get( + '/', + authenticate, + fahrzeugTypController.getAll.bind(fahrzeugTypController) +); + +// Get single vehicle type +router.get( + '/:id', + authenticate, + fahrzeugTypController.getById.bind(fahrzeugTypController) +); + +// CRUD — admin-only +router.post( + '/', + authenticate, + requirePermission('checklisten:manage_templates'), + fahrzeugTypController.create.bind(fahrzeugTypController) +); + +router.patch( + '/:id', + authenticate, + requirePermission('checklisten:manage_templates'), + fahrzeugTypController.update.bind(fahrzeugTypController) +); + +router.delete( + '/:id', + authenticate, + requirePermission('checklisten:manage_templates'), + fahrzeugTypController.delete.bind(fahrzeugTypController) +); + +// Vehicle-specific type management +router.get( + '/vehicle/:fahrzeugId', + authenticate, + fahrzeugTypController.getTypesForVehicle.bind(fahrzeugTypController) +); + +router.put( + '/vehicle/:fahrzeugId', + authenticate, + requirePermission('checklisten:manage_templates'), + fahrzeugTypController.setTypesForVehicle.bind(fahrzeugTypController) +); + +export default router; diff --git a/backend/src/routes/issue.routes.ts b/backend/src/routes/issue.routes.ts index 59806ca..6474f5d 100644 --- a/backend/src/routes/issue.routes.ts +++ b/backend/src/routes/issue.routes.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import issueController from '../controllers/issue.controller'; import { authenticate } from '../middleware/auth.middleware'; import { requirePermission } from '../middleware/rbac.middleware'; +import { uploadIssue } from '../middleware/upload'; const router = Router(); @@ -60,6 +61,13 @@ router.get( issueController.getMembers.bind(issueController) ); +// --- File management (BEFORE /:id to avoid conflict) --- +router.delete( + '/files/:fileId', + authenticate, + issueController.deleteFile.bind(issueController) +); + // --- Issue CRUD --- router.get( '/', @@ -92,6 +100,19 @@ router.post( issueController.addComment.bind(issueController) ); +router.post( + '/:id/files', + authenticate, + uploadIssue.single('file'), + issueController.uploadFile.bind(issueController) +); + +router.get( + '/:id/files', + authenticate, + issueController.getFiles.bind(issueController) +); + router.get( '/:id', authenticate, diff --git a/backend/src/server.ts b/backend/src/server.ts index de71d32..164110c 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -5,6 +5,8 @@ import { testConnection, closePool, runMigrations } from './config/database'; import { startAuditCleanupJob, stopAuditCleanupJob } from './jobs/audit-cleanup.job'; import { startNotificationJob, stopNotificationJob } from './jobs/notification-generation.job'; import { startReminderJob, stopReminderJob } from './jobs/reminder.job'; +import { startIssueReminderJob, stopIssueReminderJob } from './jobs/issue-reminder.job'; +import { startChecklistReminderJob, stopChecklistReminderJob } from './jobs/checklist-reminder.job'; import { permissionService } from './services/permission.service'; const startServer = async (): Promise => { @@ -32,6 +34,12 @@ const startServer = async (): Promise => { // Start the order reminder job startReminderJob(); + // Start the issue reminder job + startIssueReminderJob(); + + // Start the checklist reminder job + startChecklistReminderJob(); + // Start the server const server = app.listen(environment.port, () => { logger.info('Server started successfully', { @@ -56,6 +64,8 @@ const startServer = async (): Promise => { stopAuditCleanupJob(); stopNotificationJob(); stopReminderJob(); + stopIssueReminderJob(); + stopChecklistReminderJob(); server.close(async () => { logger.info('HTTP server closed'); diff --git a/backend/src/services/checklist.service.ts b/backend/src/services/checklist.service.ts new file mode 100644 index 0000000..3d4f168 --- /dev/null +++ b/backend/src/services/checklist.service.ts @@ -0,0 +1,650 @@ +import pool from '../config/database'; +import logger from '../utils/logger'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function calculateNextDueDate(intervall: string | null, intervall_tage: number | null): Date | null { + const now = new Date(); + if (intervall_tage && intervall_tage > 0) { + now.setDate(now.getDate() + intervall_tage); + return now; + } + switch (intervall) { + case 'weekly': + now.setDate(now.getDate() + 7); + return now; + case 'monthly': + now.setMonth(now.getMonth() + 1); + return now; + case 'yearly': + now.setFullYear(now.getFullYear() + 1); + return now; + default: + return null; + } +} + +// --------------------------------------------------------------------------- +// Vorlagen (Templates) +// --------------------------------------------------------------------------- + +async function getVorlagen(filter?: { fahrzeug_typ_id?: number; aktiv?: boolean }) { + try { + const conditions: string[] = []; + const values: any[] = []; + let idx = 1; + + if (filter?.fahrzeug_typ_id !== undefined) { + conditions.push(`v.fahrzeug_typ_id = $${idx}`); + values.push(filter.fahrzeug_typ_id); + idx++; + } + if (filter?.aktiv !== undefined) { + conditions.push(`v.aktiv = $${idx}`); + values.push(filter.aktiv); + idx++; + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const result = await pool.query( + `SELECT v.*, ft.name AS fahrzeug_typ_name + FROM checklist_vorlagen v + LEFT JOIN fahrzeug_typen ft ON ft.id = v.fahrzeug_typ_id + ${where} + ORDER BY v.name ASC`, + values + ); + return result.rows; + } catch (error) { + logger.error('ChecklistService.getVorlagen failed', { error }); + throw new Error('Vorlagen konnten nicht geladen werden'); + } +} + +async function getVorlageById(id: number) { + try { + const vorlageResult = await pool.query( + `SELECT 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.id = $1`, + [id] + ); + if (vorlageResult.rows.length === 0) return null; + + const vorlage = vorlageResult.rows[0]; + const itemsResult = await pool.query( + `SELECT * FROM checklist_vorlage_items WHERE vorlage_id = $1 ORDER BY sort_order ASC, id ASC`, + [id] + ); + vorlage.items = itemsResult.rows; + return vorlage; + } catch (error) { + logger.error('ChecklistService.getVorlageById failed', { error, id }); + throw new Error('Vorlage konnte nicht geladen werden'); + } +} + +async function createVorlage(data: { + name: string; + fahrzeug_typ_id?: number | 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) + RETURNING *`, + [ + data.name, + data.fahrzeug_typ_id ?? null, + data.intervall ?? null, + data.intervall_tage ?? null, + data.beschreibung ?? null, + ] + ); + return result.rows[0]; + } catch (error) { + logger.error('ChecklistService.createVorlage failed', { error }); + throw new Error('Vorlage konnte nicht erstellt werden'); + } +} + +async function updateVorlage(id: number, data: { + name?: string; + fahrzeug_typ_id?: number | null; + intervall?: string | null; + intervall_tage?: number | null; + beschreibung?: string | null; + aktiv?: boolean; +}) { + try { + const setClauses: string[] = []; + const values: any[] = []; + let idx = 1; + + 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 ('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++; } + if (data.aktiv !== undefined) { setClauses.push(`aktiv = $${idx}`); values.push(data.aktiv); idx++; } + + if (setClauses.length === 0) return getVorlageById(id); + + values.push(id); + const result = await pool.query( + `UPDATE checklist_vorlagen SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ); + return result.rows[0] || null; + } catch (error) { + logger.error('ChecklistService.updateVorlage failed', { error, id }); + throw new Error('Vorlage konnte nicht aktualisiert werden'); + } +} + +async function deleteVorlage(id: number) { + try { + const result = await pool.query( + `DELETE FROM checklist_vorlagen WHERE id = $1 RETURNING *`, + [id] + ); + return result.rows[0] || null; + } catch (error) { + logger.error('ChecklistService.deleteVorlage failed', { error, id }); + throw new Error('Vorlage konnte nicht gelöscht werden'); + } +} + +// --------------------------------------------------------------------------- +// Vorlage Items (Template line items) +// --------------------------------------------------------------------------- + +async function getVorlageItems(vorlageId: number) { + try { + const result = await pool.query( + `SELECT * FROM checklist_vorlage_items WHERE vorlage_id = $1 ORDER BY sort_order ASC, id ASC`, + [vorlageId] + ); + return result.rows; + } catch (error) { + logger.error('ChecklistService.getVorlageItems failed', { error, vorlageId }); + throw new Error('Vorlage-Items konnten nicht geladen werden'); + } +} + +async function addVorlageItem(vorlageId: number, data: { + bezeichnung: string; + beschreibung?: string | null; + pflicht?: boolean; + sort_order?: number; +}) { + try { + const result = await pool.query( + `INSERT INTO checklist_vorlage_items (vorlage_id, bezeichnung, beschreibung, pflicht, sort_order) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [vorlageId, data.bezeichnung, data.beschreibung ?? null, data.pflicht ?? true, data.sort_order ?? 0] + ); + return result.rows[0]; + } catch (error) { + logger.error('ChecklistService.addVorlageItem failed', { error, vorlageId }); + throw new Error('Vorlage-Item konnte nicht erstellt werden'); + } +} + +async function updateVorlageItem(id: number, data: { + bezeichnung?: string; + beschreibung?: string | null; + pflicht?: boolean; + sort_order?: number; +}) { + 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 (setClauses.length === 0) { + const r = await pool.query(`SELECT * FROM checklist_vorlage_items WHERE id = $1`, [id]); + return r.rows[0] || null; + } + + values.push(id); + const result = await pool.query( + `UPDATE checklist_vorlage_items SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ); + return result.rows[0] || null; + } catch (error) { + logger.error('ChecklistService.updateVorlageItem failed', { error, id }); + throw new Error('Vorlage-Item konnte nicht aktualisiert werden'); + } +} + +async function deleteVorlageItem(id: number) { + try { + const result = await pool.query( + `DELETE FROM checklist_vorlage_items WHERE id = $1 RETURNING *`, + [id] + ); + return result.rows[0] || null; + } catch (error) { + logger.error('ChecklistService.deleteVorlageItem failed', { error, id }); + throw new Error('Vorlage-Item konnte nicht gelöscht werden'); + } +} + +// --------------------------------------------------------------------------- +// Fahrzeug-spezifische Items +// --------------------------------------------------------------------------- + +async function getVehicleItems(fahrzeugId: string) { + try { + const result = await pool.query( + `SELECT * FROM fahrzeug_checklist_items WHERE fahrzeug_id = $1 AND aktiv = true ORDER BY sort_order ASC, id ASC`, + [fahrzeugId] + ); + return result.rows; + } catch (error) { + logger.error('ChecklistService.getVehicleItems failed', { error, fahrzeugId }); + throw new Error('Fahrzeug-Items konnten nicht geladen werden'); + } +} + +async function addVehicleItem(fahrzeugId: string, data: { + bezeichnung: string; + beschreibung?: string | null; + pflicht?: boolean; + sort_order?: number; +}) { + try { + const result = await pool.query( + `INSERT INTO fahrzeug_checklist_items (fahrzeug_id, bezeichnung, beschreibung, pflicht, sort_order) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [fahrzeugId, data.bezeichnung, data.beschreibung ?? null, data.pflicht ?? true, data.sort_order ?? 0] + ); + return result.rows[0]; + } catch (error) { + logger.error('ChecklistService.addVehicleItem failed', { error, fahrzeugId }); + throw new Error('Fahrzeug-Item konnte nicht erstellt werden'); + } +} + +async function updateVehicleItem(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 fahrzeug_checklist_items WHERE id = $1`, [id]); + return r.rows[0] || null; + } + + values.push(id); + const result = await pool.query( + `UPDATE fahrzeug_checklist_items SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ); + return result.rows[0] || null; + } catch (error) { + logger.error('ChecklistService.updateVehicleItem failed', { error, id }); + throw new Error('Fahrzeug-Item konnte nicht aktualisiert werden'); + } +} + +async function deleteVehicleItem(id: number) { + try { + const result = await pool.query( + `UPDATE fahrzeug_checklist_items SET aktiv = false WHERE id = $1 RETURNING *`, + [id] + ); + return result.rows[0] || null; + } catch (error) { + logger.error('ChecklistService.deleteVehicleItem failed', { error, id }); + throw new Error('Fahrzeug-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) + 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.fahrzeug_typ_id IS NULL + OR v.fahrzeug_typ_id IN ( + SELECT fahrzeug_typ_id FROM fahrzeug_fahrzeug_typen WHERE fahrzeug_id = $1 + ) + ) + ORDER BY v.name ASC`, + [fahrzeugId] + ); + + // 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.getTemplatesForVehicle failed', { error, fahrzeugId }); + throw new Error('Checklisten für Fahrzeug konnten nicht geladen werden'); + } +} + +// --------------------------------------------------------------------------- +// Ausführungen (Executions) +// --------------------------------------------------------------------------- + +async function startExecution(fahrzeugId: string, vorlageId: number, userId: string) { + 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) + RETURNING *`, + [fahrzeugId, vorlageId, userId] + ); + const execution = execResult.rows[0]; + + // Copy template items into execution items + const vorlageItems = await client.query( + `SELECT * FROM checklist_vorlage_items WHERE vorlage_id = $1 ORDER BY sort_order ASC, id ASC`, + [vorlageId] + ); + for (const item of vorlageItems.rows) { + await client.query( + `INSERT INTO checklist_ausfuehrung_items (ausfuehrung_id, vorlage_item_id, bezeichnung) + VALUES ($1, $2, $3)`, + [execution.id, item.id, item.bezeichnung] + ); + } + + // 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] + ); + } + + await client.query('COMMIT'); + + // Fetch the complete execution with items + return getExecutionById(execution.id); + } catch (error) { + await client.query('ROLLBACK').catch(() => {}); + logger.error('ChecklistService.startExecution failed', { error, fahrzeugId, vorlageId }); + throw new Error('Checklist-Ausführung konnte nicht gestartet werden'); + } finally { + client.release(); + } +} + +async function getExecutionById(id: string) { + try { + const execResult = await pool.query( + `SELECT a.*, + f.bezeichnung AS fahrzeug_name, f.kurzname AS fahrzeug_kurzname, + 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 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 + WHERE a.id = $1`, + [id] + ); + if (execResult.rows.length === 0) return null; + + const execution = execResult.rows[0]; + const itemsResult = await pool.query( + `SELECT * FROM checklist_ausfuehrung_items WHERE ausfuehrung_id = $1 ORDER BY id ASC`, + [id] + ); + execution.items = itemsResult.rows; + return execution; + } catch (error) { + logger.error('ChecklistService.getExecutionById failed', { error, id }); + throw new Error('Ausführung konnte nicht geladen werden'); + } +} + +async function submitExecution( + id: string, + items: Array<{ itemId: number; ergebnis: string; kommentar?: string }>, + notizen: string | null, + userId: string, +) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // Update each item's result + for (const item of items) { + await client.query( + `UPDATE checklist_ausfuehrung_items SET ergebnis = $1, kommentar = $2 WHERE id = $3 AND ausfuehrung_id = $4`, + [item.ergebnis, item.kommentar ?? null, item.itemId, id] + ); + } + + // 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 + 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 + WHERE ai.ausfuehrung_id = $1 + AND (COALESCE(vi.pflicht, fi.pflicht, true) = true)`, + [id] + ); + + const allPflichtOk = pflichtCheck.rows.every((r: any) => r.ergebnis === 'ok'); + const newStatus = allPflichtOk ? 'abgeschlossen' : 'unvollstaendig'; + + await client.query( + `UPDATE checklist_ausfuehrungen SET status = $1, ausgefuehrt_am = NOW(), notizen = $2 WHERE id = $3`, + [newStatus, notizen, id] + ); + + // Update checklist_faelligkeit if completed + if (allPflichtOk) { + const exec = await client.query(`SELECT vorlage_id, fahrzeug_id FROM checklist_ausfuehrungen WHERE id = $1`, [id]); + if (exec.rows.length > 0) { + const { vorlage_id, fahrzeug_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] + ); + } + } + } + } + + await client.query('COMMIT'); + return getExecutionById(id); + } catch (error) { + await client.query('ROLLBACK').catch(() => {}); + logger.error('ChecklistService.submitExecution failed', { error, id }); + throw new Error('Ausführung konnte nicht abgeschlossen werden'); + } finally { + client.release(); + } +} + +async function approveExecution(id: string, userId: string) { + try { + const result = await pool.query( + `UPDATE checklist_ausfuehrungen + SET status = 'freigegeben', freigegeben_von = $1, freigegeben_am = NOW() + WHERE id = $2 AND status IN ('abgeschlossen', 'unvollstaendig') + RETURNING *`, + [userId, id] + ); + if (result.rows.length === 0) return null; + return getExecutionById(id); + } catch (error) { + logger.error('ChecklistService.approveExecution failed', { error, id }); + throw new Error('Freigabe konnte nicht erteilt werden'); + } +} + +async function getExecutions(filter?: { fahrzeugId?: string; vorlageId?: number; status?: string }) { + try { + const conditions: string[] = []; + const values: any[] = []; + let idx = 1; + + if (filter?.fahrzeugId) { + conditions.push(`a.fahrzeug_id = $${idx}`); + values.push(filter.fahrzeugId); + idx++; + } + if (filter?.vorlageId) { + conditions.push(`a.vorlage_id = $${idx}`); + values.push(filter.vorlageId); + idx++; + } + if (filter?.status) { + conditions.push(`a.status = $${idx}`); + values.push(filter.status); + idx++; + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const result = await pool.query( + `SELECT a.*, + f.bezeichnung AS fahrzeug_name, f.kurzname AS fahrzeug_kurzname, + 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 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 + ${where} + ORDER BY a.created_at DESC`, + values + ); + return result.rows; + } catch (error) { + logger.error('ChecklistService.getExecutions failed', { error }); + throw new Error('Ausführungen konnten nicht geladen werden'); + } +} + +// --------------------------------------------------------------------------- +// Fälligkeiten (Due dates) +// --------------------------------------------------------------------------- + +async function getOverdueChecklists() { + try { + const result = await pool.query(` + SELECT cf.*, f.bezeichnung AS fahrzeug_name, f.kurzname AS fahrzeug_kurzname, + v.name AS vorlage_name + 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.naechste_faellig_am <= CURRENT_DATE + ORDER BY cf.naechste_faellig_am ASC + `); + return result.rows; + } catch (error) { + logger.error('ChecklistService.getOverdueChecklists failed', { error }); + throw new Error('Überfällige Checklisten konnten nicht geladen werden'); + } +} + +async function getDueChecklists(fahrzeugId: string) { + try { + const result = await pool.query( + `SELECT cf.*, v.name AS vorlage_name, v.intervall, v.intervall_tage + FROM checklist_faelligkeit cf + JOIN checklist_vorlagen v ON v.id = cf.vorlage_id AND v.aktiv = true + WHERE cf.fahrzeug_id = $1 + ORDER BY cf.naechste_faellig_am ASC`, + [fahrzeugId] + ); + return result.rows; + } catch (error) { + logger.error('ChecklistService.getDueChecklists failed', { error, fahrzeugId }); + throw new Error('Fälligkeiten konnten nicht geladen werden'); + } +} + +export default { + getVorlagen, + getVorlageById, + createVorlage, + updateVorlage, + deleteVorlage, + getVorlageItems, + addVorlageItem, + updateVorlageItem, + deleteVorlageItem, + getVehicleItems, + addVehicleItem, + updateVehicleItem, + deleteVehicleItem, + getTemplatesForVehicle, + startExecution, + getExecutionById, + submitExecution, + approveExecution, + getExecutions, + getOverdueChecklists, + getDueChecklists, +}; diff --git a/backend/src/services/fahrzeugTyp.service.ts b/backend/src/services/fahrzeugTyp.service.ts new file mode 100644 index 0000000..3b795b4 --- /dev/null +++ b/backend/src/services/fahrzeugTyp.service.ts @@ -0,0 +1,142 @@ +import pool from '../config/database'; +import logger from '../utils/logger'; + +class FahrzeugTypService { + async getAll() { + try { + const result = await pool.query( + `SELECT * FROM fahrzeug_typen ORDER BY name ASC` + ); + return result.rows; + } catch (error) { + logger.error('FahrzeugTypService.getAll failed', { error }); + throw new Error('Fahrzeug-Typen konnten nicht geladen werden'); + } + } + + async getById(id: number) { + try { + const result = await pool.query( + `SELECT * FROM fahrzeug_typen WHERE id = $1`, + [id] + ); + return result.rows[0] || null; + } catch (error) { + logger.error('FahrzeugTypService.getById failed', { error, id }); + throw new Error('Fahrzeug-Typ konnte nicht geladen werden'); + } + } + + async create(data: { name: string; beschreibung?: string; icon?: string }) { + try { + const result = await pool.query( + `INSERT INTO fahrzeug_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('FahrzeugTypService.create failed', { error }); + throw new Error('Fahrzeug-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 fahrzeug_typen SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ); + return result.rows[0] || null; + } catch (error) { + logger.error('FahrzeugTypService.update failed', { error, id }); + throw new Error('Fahrzeug-Typ konnte nicht aktualisiert werden'); + } + } + + async delete(id: number) { + try { + const result = await pool.query( + `DELETE FROM fahrzeug_typen WHERE id = $1 RETURNING *`, + [id] + ); + return result.rows[0] || null; + } catch (error) { + logger.error('FahrzeugTypService.delete failed', { error, id }); + throw new Error('Fahrzeug-Typ konnte nicht gelöscht werden'); + } + } + + async getTypesForVehicle(fahrzeugId: string) { + try { + const result = await pool.query( + `SELECT ft.* FROM fahrzeug_typen ft + JOIN fahrzeug_fahrzeug_typen fft ON fft.fahrzeug_typ_id = ft.id + WHERE fft.fahrzeug_id = $1 + ORDER BY ft.name ASC`, + [fahrzeugId] + ); + return result.rows; + } catch (error) { + logger.error('FahrzeugTypService.getTypesForVehicle failed', { error, fahrzeugId }); + throw new Error('Fahrzeug-Typen konnten nicht geladen werden'); + } + } + + async setTypesForVehicle(fahrzeugId: string, typIds: number[]) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + await client.query( + `DELETE FROM fahrzeug_fahrzeug_typen WHERE fahrzeug_id = $1`, + [fahrzeugId] + ); + + for (const typId of typIds) { + await client.query( + `INSERT INTO fahrzeug_fahrzeug_typen (fahrzeug_id, fahrzeug_typ_id) VALUES ($1, $2)`, + [fahrzeugId, typId] + ); + } + + await client.query('COMMIT'); + + return this.getTypesForVehicle(fahrzeugId); + } catch (error) { + await client.query('ROLLBACK').catch(() => {}); + logger.error('FahrzeugTypService.setTypesForVehicle failed', { error, fahrzeugId }); + throw new Error('Fahrzeug-Typen konnten nicht gesetzt werden'); + } finally { + client.release(); + } + } +} + +export default new FahrzeugTypService(); diff --git a/backend/src/services/issue.service.ts b/backend/src/services/issue.service.ts index b1c1fa5..6808a88 100644 --- a/backend/src/services/issue.service.ts +++ b/backend/src/services/issue.service.ts @@ -1,5 +1,6 @@ import pool from '../config/database'; import logger from '../utils/logger'; +import fs from 'fs'; interface IssueFilters { typ_id?: number[]; @@ -92,13 +93,13 @@ async function getIssueById(id: number) { } async function createIssue( - data: { titel: string; beschreibung?: string; typ_id?: number; prioritaet?: string }, + data: { titel: string; beschreibung?: string; typ_id?: number; prioritaet?: string; faellig_am?: string | null }, userId: string ) { try { const result = await pool.query( - `INSERT INTO issues (titel, beschreibung, typ_id, prioritaet, erstellt_von) - VALUES ($1, $2, $3, $4, $5) + `INSERT INTO issues (titel, beschreibung, typ_id, prioritaet, erstellt_von, faellig_am) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, [ data.titel, @@ -106,6 +107,7 @@ async function createIssue( data.typ_id || 3, data.prioritaet || 'mittel', userId, + data.faellig_am || null, ] ); return getIssueById(result.rows[0].id); @@ -127,6 +129,7 @@ async function updateIssue( prioritaet?: string; status?: string; zugewiesen_an?: string | null; + faellig_am?: string | null; } ) { try { @@ -164,6 +167,11 @@ async function updateIssue( values.push(data.zugewiesen_an ?? null); idx++; } + if ('faellig_am' in data) { + setClauses.push(`faellig_am = $${idx}`); + values.push(data.faellig_am ?? null); + idx++; + } if (setClauses.length === 0) { return getIssueById(id); @@ -594,6 +602,92 @@ async function deleteIssuePriority(id: number) { } +// --------------------------------------------------------------------------- +// Files (Issue Dateien) +// --------------------------------------------------------------------------- + +async function addFile( + issueId: number, + fileData: { dateiname: string; dateipfad: string; dateityp: string; dateigroesse: number }, + userId: string +) { + try { + const result = await pool.query( + `INSERT INTO issue_dateien (issue_id, dateiname, dateipfad, dateityp, dateigroesse, hochgeladen_von) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [issueId, fileData.dateiname, fileData.dateipfad, fileData.dateityp, fileData.dateigroesse, userId] + ); + return result.rows[0]; + } catch (error) { + logger.error('IssueService.addFile failed', { error, issueId }); + throw new Error('Datei konnte nicht gespeichert werden'); + } +} + +async function getFiles(issueId: number) { + try { + const result = await pool.query( + `SELECT * FROM issue_dateien WHERE issue_id = $1 ORDER BY hochgeladen_am DESC`, + [issueId] + ); + return result.rows; + } catch (error) { + logger.error('IssueService.getFiles failed', { error, issueId }); + throw new Error('Dateien konnten nicht geladen werden'); + } +} + +async function deleteFile(fileId: string, userId: string) { + try { + const fileResult = await pool.query( + `SELECT * FROM issue_dateien WHERE id = $1`, + [fileId] + ); + if (fileResult.rows.length === 0) return null; + + const file = fileResult.rows[0]; + await pool.query(`DELETE FROM issue_dateien WHERE id = $1`, [fileId]); + + // Remove from disk + try { + if (file.dateipfad && fs.existsSync(file.dateipfad)) { + fs.unlinkSync(file.dateipfad); + } + } catch (err) { + logger.warn('Failed to delete issue file from disk', { path: file.dateipfad, error: err }); + } + + return { dateipfad: file.dateipfad, dateiname: file.dateiname }; + } catch (error) { + logger.error('IssueService.deleteFile failed', { error, fileId }); + throw new Error('Datei konnte nicht gelöscht werden'); + } +} + +// --------------------------------------------------------------------------- +// Overdue Issues +// --------------------------------------------------------------------------- + +async function getOverdueIssues() { + try { + const result = await pool.query(` + SELECT i.*, u1.name AS erstellt_von_name, u2.name AS zugewiesen_an_name + FROM issues i + LEFT JOIN users u1 ON u1.id = i.erstellt_von + LEFT JOIN users u2 ON u2.id = i.zugewiesen_an + LEFT JOIN issue_statuses s ON s.schluessel = i.status + WHERE i.faellig_am < NOW() + AND (s.ist_abschluss = false OR s.ist_abschluss IS NULL) + ORDER BY i.faellig_am ASC + `); + return result.rows; + } catch (error) { + logger.error('IssueService.getOverdueIssues failed', { error }); + throw new Error('Überfällige Issues konnten nicht geladen werden'); + } +} + export default { getIssues, getIssueById, @@ -618,5 +712,9 @@ export default { createIssuePriority, updateIssuePriority, deleteIssuePriority, + addFile, + getFiles, + deleteFile, + getOverdueIssues, UNASSIGN, }; diff --git a/backend/src/services/vehicle.service.ts b/backend/src/services/vehicle.service.ts index 9282959..155aad7 100644 --- a/backend/src/services/vehicle.service.ts +++ b/backend/src/services/vehicle.service.ts @@ -137,6 +137,16 @@ class VehicleService { ? { titel: lehrgang.rows[0].titel, beginn: lehrgang.rows[0].beginn, ende: lehrgang.rows[0].ende } : null; + // Fetch vehicle types via junction table + const typenResult = await pool.query( + `SELECT ft.* FROM fahrzeug_typen ft + JOIN fahrzeug_fahrzeug_typen fft ON fft.fahrzeug_typ_id = ft.id + WHERE fft.fahrzeug_id = $1 + ORDER BY ft.name ASC`, + [id] + ); + (vehicle as any).typen = typenResult.rows; + return vehicle; } catch (error) { logger.error('VehicleService.getVehicleById failed', { error, id }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5ad334a..e26b83d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,9 @@ "name": "feuerwehr-dashboard-frontend", "version": "0.0.1", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.14.18", @@ -301,6 +304,60 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -3222,6 +3279,11 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://npm.apple.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7b964c7..bdfa848 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,9 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.14.18", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 50d7777..c8dc377 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,6 +36,8 @@ import AusruestungsanfrageDetail from './pages/AusruestungsanfrageDetail'; import AusruestungsanfrageZuBestellung from './pages/AusruestungsanfrageZuBestellung'; import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail'; import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu'; +import Checklisten from './pages/Checklisten'; +import ChecklistAusfuehrung from './pages/ChecklistAusfuehrung'; import Issues from './pages/Issues'; import IssueDetail from './pages/IssueDetail'; import IssueNeu from './pages/IssueNeu'; @@ -342,6 +344,22 @@ function App() { } /> + + + + } + /> + + + + } + /> > = { 'Bearbeiten': ['create', 'change_status', 'edit', 'delete'], 'Admin': ['edit_settings'], }, + checklisten: { + 'Ansehen': ['view'], + 'Ausführen': ['execute', 'approve'], + 'Verwaltung': ['manage_templates'], + 'Widget': ['widget'], + }, admin: { 'Allgemein': ['view', 'write'], 'Services': ['view_services', 'edit_services'], diff --git a/frontend/src/components/dashboard/ChecklistWidget.tsx b/frontend/src/components/dashboard/ChecklistWidget.tsx new file mode 100644 index 0000000..22790ad --- /dev/null +++ b/frontend/src/components/dashboard/ChecklistWidget.tsx @@ -0,0 +1,92 @@ +import { Card, CardContent, Typography, Box, Chip, Skeleton } from '@mui/material'; +import { AssignmentTurnedIn, Warning } from '@mui/icons-material'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { checklistenApi } from '../../services/checklisten'; + +function ChecklistWidget() { + const navigate = useNavigate(); + + const { data: overdue, isLoading, isError } = useQuery({ + queryKey: ['checklist-overdue'], + queryFn: checklistenApi.getOverdue, + refetchInterval: 5 * 60 * 1000, + retry: 1, + }); + + const overdueItems = overdue ?? []; + + if (isLoading) { + return ( + + + Checklisten + + + + ); + } + + if (isError) { + return ( + + + Checklisten + + Checklisten konnten nicht geladen werden. + + + + ); + } + + return ( + navigate('/checklisten')}> + + + + Checklisten + {overdueItems.length > 0 && ( + } + label={overdueItems.length} + color="error" + size="small" + /> + )} + + + + {overdueItems.length === 0 ? ( + Alle Checklisten aktuell + ) : ( + + {overdueItems.slice(0, 5).map((item) => { + const days = Math.ceil((Date.now() - new Date(item.naechste_faellig_am).getTime()) / 86400000); + return ( + + + {item.fahrzeug_name} + + 0 ? `${days}d` : 'heute'}`} + color={days > 7 ? 'error' : days > 0 ? 'warning' : 'info'} + size="small" + variant="outlined" + /> + + ); + })} + {overdueItems.length > 5 && ( + + + {overdueItems.length - 5} weitere + + )} + + )} + + + ); +} + +export default ChecklistWidget; diff --git a/frontend/src/components/dashboard/SortableWidget.tsx b/frontend/src/components/dashboard/SortableWidget.tsx new file mode 100644 index 0000000..60304e9 --- /dev/null +++ b/frontend/src/components/dashboard/SortableWidget.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Box, IconButton } from '@mui/material'; +import { DragIndicator } from '@mui/icons-material'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; + +interface SortableWidgetProps { + id: string; + editMode: boolean; + children: React.ReactNode; +} + +export default function SortableWidget({ id, editMode, children }: SortableWidgetProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id, disabled: !editMode }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + position: 'relative', + }; + + return ( + + {editMode && ( + + + + )} + {children} + + ); +} diff --git a/frontend/src/components/dashboard/index.ts b/frontend/src/components/dashboard/index.ts index b729bba..852b0c0 100644 --- a/frontend/src/components/dashboard/index.ts +++ b/frontend/src/components/dashboard/index.ts @@ -22,3 +22,5 @@ export { default as BestellungenWidget } from './BestellungenWidget'; export { default as AusruestungsanfrageWidget } from './AusruestungsanfrageWidget'; export { default as IssueQuickAddWidget } from './IssueQuickAddWidget'; export { default as IssueOverviewWidget } from './IssueOverviewWidget'; +export { default as ChecklistWidget } from './ChecklistWidget'; +export { default as SortableWidget } from './SortableWidget'; diff --git a/frontend/src/components/fahrzeuge/FahrzeugChecklistTab.tsx b/frontend/src/components/fahrzeuge/FahrzeugChecklistTab.tsx new file mode 100644 index 0000000..be89b8e --- /dev/null +++ b/frontend/src/components/fahrzeuge/FahrzeugChecklistTab.tsx @@ -0,0 +1,252 @@ +import React, { useState } from 'react'; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + FormControlLabel, + IconButton, + Paper, + Switch, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material'; +import { Add, Delete as DeleteIcon, PlayArrow, Warning } from '@mui/icons-material'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { usePermissionContext } from '../../contexts/PermissionContext'; +import { useNotification } from '../../contexts/NotificationContext'; +import { checklistenApi } from '../../services/checklisten'; +import { CHECKLIST_STATUS_LABELS, CHECKLIST_STATUS_COLORS } from '../../types/checklist.types'; +import type { CreateFahrzeugItemPayload } from '../../types/checklist.types'; + +// ── Helpers ── + +const formatDate = (iso?: string) => + iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '\u2013'; + +// ══════════════════════════════════════════════════════════════════════════════ +// Component +// ══════════════════════════════════════════════════════════════════════════════ + +interface FahrzeugChecklistTabProps { + fahrzeugId: string; +} + +const FahrzeugChecklistTab: React.FC = ({ fahrzeugId }) => { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { hasPermission } = usePermissionContext(); + const { showSuccess, showError } = useNotification(); + const canManage = hasPermission('checklisten:manage_templates'); + const canExecute = hasPermission('checklisten:execute'); + + // ── Queries ── + const { data: vehicleItems = [], isLoading: itemsLoading } = useQuery({ + queryKey: ['checklisten-fahrzeug-items', fahrzeugId], + queryFn: () => checklistenApi.getVehicleItems(fahrzeugId), + }); + + const { data: templates = [], isLoading: templatesLoading } = useQuery({ + queryKey: ['checklisten-fahrzeug-checklisten', fahrzeugId], + queryFn: () => checklistenApi.getChecklistenForVehicle(fahrzeugId), + }); + + const { data: executions = [], isLoading: executionsLoading } = useQuery({ + queryKey: ['checklisten-ausfuehrungen', { fahrzeug_id: fahrzeugId }], + queryFn: () => checklistenApi.getExecutions({ fahrzeug_id: fahrzeugId }), + }); + + const { data: overdue = [] } = useQuery({ + queryKey: ['checklisten-faellig'], + queryFn: checklistenApi.getOverdue, + }); + + const vehicleOverdue = overdue.filter((f) => f.fahrzeug_id === fahrzeugId); + + // ── Vehicle items management ── + const [newItem, setNewItem] = useState({ bezeichnung: '', pflicht: false, sort_order: 0 }); + + const addItemMutation = useMutation({ + mutationFn: (data: CreateFahrzeugItemPayload) => checklistenApi.addVehicleItem(fahrzeugId, data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['checklisten-fahrzeug-items', fahrzeugId] }); setNewItem({ bezeichnung: '', pflicht: false, sort_order: 0 }); showSuccess('Item hinzugef\u00fcgt'); }, + onError: () => showError('Fehler beim Hinzuf\u00fcgen'), + }); + + const deleteItemMutation = useMutation({ + mutationFn: (id: number) => checklistenApi.deleteVehicleItem(id), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['checklisten-fahrzeug-items', fahrzeugId] }); showSuccess('Item entfernt'); }, + onError: () => showError('Fehler beim Entfernen'), + }); + + const toggleItemMutation = useMutation({ + mutationFn: ({ id, aktiv }: { id: number; aktiv: boolean }) => checklistenApi.updateVehicleItem(id, { aktiv }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['checklisten-fahrzeug-items', fahrzeugId] }), + onError: () => showError('Fehler beim Aktualisieren'), + }); + + return ( + + {/* Section 1: Vehicle-specific items */} + {canManage && ( + + Fahrzeugspezifische Checklisten-Items + + {itemsLoading ? ( + + ) : ( + <> + + + + + Bezeichnung + Pflicht + Aktiv + Aktionen + + + + {vehicleItems.length === 0 ? ( + Keine fahrzeugspezifischen Items + ) : ( + vehicleItems.map((item) => ( + + {item.bezeichnung} + {item.pflicht ? : '\u2013'} + + toggleItemMutation.mutate({ id: item.id, aktiv: !item.aktiv })} + /> + + + deleteItemMutation.mutate(item.id)}> + + + + + )) + )} + +
+
+ + + setNewItem((n) => ({ ...n, bezeichnung: e.target.value }))} sx={{ flexGrow: 1 }} /> + setNewItem((n) => ({ ...n, pflicht: e.target.checked }))} />} label="Pflicht" /> + + + + )} +
+ )} + + {/* Section 2: Applicable templates */} + + Anwendbare Vorlagen + + {templatesLoading ? ( + + ) : templates.length === 0 ? ( + Keine Vorlagen f\u00fcr dieses Fahrzeug. + ) : ( + + + + + Vorlage + Intervall + N\u00e4chste F\u00e4lligkeit + Aktion + + + + {templates.map((t) => { + const due = vehicleOverdue.find((f) => f.vorlage_id === t.id); + return ( + + {t.name} + + {t.intervall === 'weekly' ? 'W\u00f6chentlich' : t.intervall === 'monthly' ? 'Monatlich' : t.intervall === 'yearly' ? 'J\u00e4hrlich' : t.intervall === 'custom' ? `${t.intervall_tage ?? '?'} Tage` : '\u2013'} + + + {due ? ( + } label={`F\u00e4llig: ${formatDate(due.naechste_faellig_am)}`} color="error" size="small" /> + ) : ( + Aktuell + )} + + + {canExecute && ( + + )} + + + ); + })} + +
+
+ )} +
+ + {/* Section 3: Recent executions */} + + Letzte Ausf\u00fchrungen + + {executionsLoading ? ( + + ) : executions.length === 0 ? ( + Noch keine Ausf\u00fchrungen f\u00fcr dieses Fahrzeug. + ) : ( + + + + + Datum + Vorlage + Status + Ausgef\u00fchrt von + Freigegeben von + + + + {executions.slice(0, 20).map((e) => ( + navigate(`/checklisten/ausfuehrung/${e.id}`)}> + {formatDate(e.ausgefuehrt_am ?? e.created_at)} + {e.vorlage_name ?? '\u2013'} + + + + {e.ausgefuehrt_von_name ?? '\u2013'} + {e.freigegeben_von_name ?? '\u2013'} + + ))} + +
+
+ )} +
+
+ ); +}; + +export default FahrzeugChecklistTab; diff --git a/frontend/src/components/issues/KanbanBoard.tsx b/frontend/src/components/issues/KanbanBoard.tsx new file mode 100644 index 0000000..4687465 --- /dev/null +++ b/frontend/src/components/issues/KanbanBoard.tsx @@ -0,0 +1,328 @@ +import { useMemo } from 'react'; +import { + Box, Paper, Typography, Chip, Avatar, +} from '@mui/material'; +import { + BugReport, FiberNew, HelpOutline, + AccessTime as AccessTimeIcon, +} from '@mui/icons-material'; +import { + DndContext, + DragOverlay, + PointerSensor, + useSensor, + useSensors, + closestCorners, +} from '@dnd-kit/core'; +import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core'; +import { + SortableContext, + verticalListSortingStrategy, + useSortable, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { useState } from 'react'; +import type { Issue, IssueStatusDef, IssuePriorityDef } from '../../types/issue.types'; + +// ── Helpers ── + +const PRIO_COLORS: Record = { + hoch: '#d32f2f', mittel: '#ed6c02', niedrig: '#9e9e9e', +}; +const MUI_THEME_COLORS: Record = { + default: '#9e9e9e', primary: '#1976d2', secondary: '#9c27b0', + error: '#d32f2f', info: '#0288d1', success: '#2e7d32', warning: '#ed6c02', +}; +const ICON_MAP: Record = { + BugReport: , + FiberNew: , + HelpOutline: , +}; + +function getPrioColor(priorities: IssuePriorityDef[], key: string) { + return priorities.find(p => p.schluessel === key)?.farbe ?? PRIO_COLORS[key] ?? '#9e9e9e'; +} + +function isOverdue(issue: Issue): boolean { + if (!issue.faellig_am) return false; + return new Date(issue.faellig_am) < new Date(); +} + +// ── Kanban Card ── + +function KanbanCard({ + issue, + priorities, + onClick, + isDragging, +}: { + issue: Issue; + priorities: IssuePriorityDef[]; + onClick: (id: number) => void; + isDragging?: boolean; +}) { + const prioColor = getPrioColor(priorities, issue.prioritaet); + const icon = ICON_MAP[issue.typ_icon || ''] || ; + const overdue = isOverdue(issue); + + return ( + onClick(issue.id)} + sx={{ + p: 1.5, + cursor: 'pointer', + borderLeft: `3px solid ${prioColor}`, + opacity: isDragging ? 0.5 : 1, + '&:hover': { bgcolor: 'action.hover' }, + display: 'flex', + flexDirection: 'column', + gap: 0.5, + }} + > + {/* Title row with type icon */} + + + {icon} + + + {issue.titel} + + + + {/* Bottom row: assignee + overdue */} + + + {issue.zugewiesen_an_name && ( + + {issue.zugewiesen_an_name.charAt(0).toUpperCase()} + + )} + + {issue.zugewiesen_an_name || ''} + + + {overdue && ( + } + label="Überfällig" + size="small" + color="error" + variant="outlined" + sx={{ height: 20, '& .MuiChip-label': { px: 0.5, fontSize: 11 } }} + /> + )} + + + ); +} + +// ── Sortable wrapper ── + +function SortableCard({ + issue, + priorities, + onClick, +}: { + issue: Issue; + priorities: IssuePriorityDef[]; + onClick: (id: number) => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: issue.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( + + + + ); +} + +// ── Column ── + +function KanbanColumn({ + statusDef, + issues, + priorities, + onClick, +}: { + statusDef: IssueStatusDef; + issues: Issue[]; + priorities: IssuePriorityDef[]; + onClick: (id: number) => void; +}) { + const chipColor = MUI_THEME_COLORS[statusDef.farbe] ?? statusDef.farbe ?? '#9e9e9e'; + + return ( + + {/* Column header */} + + + {statusDef.bezeichnung} + {issues.length} + + + {/* Card list */} + i.id)} strategy={verticalListSortingStrategy}> + + {issues.length === 0 && ( + + Keine Issues + + )} + {issues.map((issue) => ( + + ))} + + + + ); +} + +// ── Main Board ── + +interface KanbanBoardProps { + issues: Issue[]; + statuses: IssueStatusDef[]; + priorities: IssuePriorityDef[]; + onNavigate: (issueId: number) => void; + onStatusChange: (issueId: number, newStatus: string) => void; +} + +export default function KanbanBoard({ + issues, + statuses, + priorities, + onNavigate, + onStatusChange, +}: KanbanBoardProps) { + const [activeId, setActiveId] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + ); + + // Active statuses sorted by sort_order + const activeStatuses = useMemo( + () => statuses.filter(s => s.aktiv).sort((a, b) => a.sort_order - b.sort_order), + [statuses], + ); + + // Group issues by status + const issuesByStatus = useMemo(() => { + const map: Record = {}; + for (const s of activeStatuses) map[s.schluessel] = []; + for (const issue of issues) { + if (map[issue.status]) map[issue.status].push(issue); + } + return map; + }, [issues, activeStatuses]); + + const activeIssue = activeId ? issues.find(i => i.id === activeId) : null; + + // Find which column (status) an issue id belongs to + function findStatusForIssue(id: number): string | undefined { + for (const [status, items] of Object.entries(issuesByStatus)) { + if (items.some(i => i.id === id)) return status; + } + return undefined; + } + + function handleDragStart(event: DragStartEvent) { + setActiveId(event.active.id as number); + } + + function handleDragEnd(event: DragEndEvent) { + setActiveId(null); + const { active, over } = event; + if (!over) return; + + const activeIssueId = active.id as number; + const overId = over.id; + + // Determine target status: if dropped over a column (status key) or over another card + let targetStatus: string | undefined; + + // Check if overId is a status key (column droppable) + if (typeof overId === 'string' && activeStatuses.some(s => s.schluessel === overId)) { + targetStatus = overId; + } else { + // Dropped over another card — find that card's status + targetStatus = findStatusForIssue(overId as number); + } + + if (!targetStatus) return; + + const currentStatus = findStatusForIssue(activeIssueId); + if (currentStatus === targetStatus) return; + + onStatusChange(activeIssueId, targetStatus); + } + + return ( + + + {activeStatuses.map((s) => ( + + ))} + + + + {activeIssue ? ( + {}} /> + ) : null} + + + ); +} diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx index 32aa258..7087b7c 100644 --- a/frontend/src/components/shared/Sidebar.tsx +++ b/frontend/src/components/shared/Sidebar.tsx @@ -29,6 +29,7 @@ import { BugReport, BookOnline, Forum, + AssignmentTurnedIn, } from '@mui/icons-material'; import { useNavigate, useLocation } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; @@ -136,6 +137,12 @@ const baseNavigationItems: NavigationItem[] = [ // subItems computed dynamically in navigationItems useMemo permission: 'ausruestungsanfrage:view', }, + { + text: 'Checklisten', + icon: , + path: '/checklisten', + permission: 'checklisten:view', + }, { text: 'Issues', icon: , diff --git a/frontend/src/constants/widgets.ts b/frontend/src/constants/widgets.ts index 073ca4a..12aac42 100644 --- a/frontend/src/constants/widgets.ts +++ b/frontend/src/constants/widgets.ts @@ -17,6 +17,7 @@ export const WIDGETS = [ { key: 'ausruestungsanfragen', label: 'Interne Bestellungen', defaultVisible: true }, { key: 'issueQuickAdd', label: 'Issue melden', defaultVisible: true }, { key: 'issueOverview', label: 'Issue Übersicht', defaultVisible: true }, + { key: 'checklistOverdue', label: 'Checklisten', defaultVisible: true }, ] as const; export type WidgetKey = typeof WIDGETS[number]['key']; diff --git a/frontend/src/pages/ChecklistAusfuehrung.tsx b/frontend/src/pages/ChecklistAusfuehrung.tsx new file mode 100644 index 0000000..dddf1dc --- /dev/null +++ b/frontend/src/pages/ChecklistAusfuehrung.tsx @@ -0,0 +1,314 @@ +import { useState, useEffect } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Divider, + Paper, + Radio, + RadioGroup, + FormControlLabel, + TextField, + Typography, +} from '@mui/material'; +import { ArrowBack, CheckCircle, Cancel, RemoveCircle } from '@mui/icons-material'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { usePermissionContext } from '../contexts/PermissionContext'; +import { useNotification } from '../contexts/NotificationContext'; +import { checklistenApi } from '../services/checklisten'; +import { CHECKLIST_STATUS_LABELS, CHECKLIST_STATUS_COLORS } from '../types/checklist.types'; +import type { ChecklistAusfuehrungItem } from '../types/checklist.types'; + +// ── Helpers ── + +const formatDate = (iso?: string) => + iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '\u2013'; + +const ERGEBNIS_ICONS: Record = { + ok: , + nok: , + na: , +}; + +// ══════════════════════════════════════════════════════════════════════════════ +// Component +// ══════════════════════════════════════════════════════════════════════════════ + +export default function ChecklistAusfuehrung() { + const { id } = useParams<{ id: string }>(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { hasPermission } = usePermissionContext(); + const { showSuccess, showError } = useNotification(); + + const isNew = id === 'new'; + const canApprove = hasPermission('checklisten:approve'); + + // ── Start new execution ── + const [startingExecution, setStartingExecution] = useState(false); + + useEffect(() => { + if (!isNew || startingExecution) return; + const fahrzeugId = searchParams.get('fahrzeug'); + const vorlageId = searchParams.get('vorlage'); + if (!fahrzeugId || !vorlageId) { + showError('Fahrzeug und Vorlage sind erforderlich'); + navigate('/checklisten'); + return; + } + setStartingExecution(true); + checklistenApi.startExecution(fahrzeugId, Number(vorlageId)) + .then((exec) => navigate(`/checklisten/ausfuehrung/${exec.id}`, { replace: true })) + .catch(() => { showError('Checkliste konnte nicht gestartet werden'); navigate('/checklisten'); }); + }, [isNew, searchParams, navigate, showError, startingExecution]); + + // ── Fetch existing execution ── + const { data: execution, isLoading, isError } = useQuery({ + queryKey: ['checklisten-ausfuehrung', id], + queryFn: () => checklistenApi.getExecution(id!), + enabled: !isNew && !!id, + }); + + // ── Item state ── + const [itemResults, setItemResults] = useState>({}); + const [notizen, setNotizen] = useState(''); + + useEffect(() => { + if (!execution?.items) return; + const results: Record = {}; + for (const item of execution.items) { + results[item.id] = { ergebnis: item.ergebnis ?? 'ok', kommentar: item.kommentar ?? '' }; + } + setItemResults(results); + setNotizen(execution.notizen ?? ''); + }, [execution]); + + const setItemResult = (itemId: number, ergebnis: 'ok' | 'nok' | 'na') => { + setItemResults((prev) => ({ ...prev, [itemId]: { ...prev[itemId], ergebnis } })); + }; + + const setItemComment = (itemId: number, kommentar: string) => { + setItemResults((prev) => ({ ...prev, [itemId]: { ...prev[itemId], kommentar } })); + }; + + // ── Submit ── + const submitMutation = useMutation({ + mutationFn: () => checklistenApi.submitExecution(id!, { + items: Object.entries(itemResults).map(([itemId, r]) => ({ itemId: Number(itemId), ergebnis: r.ergebnis, kommentar: r.kommentar || undefined })), + notizen: notizen || undefined, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['checklisten-ausfuehrung', id] }); + queryClient.invalidateQueries({ queryKey: ['checklisten-ausfuehrungen'] }); + queryClient.invalidateQueries({ queryKey: ['checklisten-faellig'] }); + showSuccess('Checkliste abgeschlossen'); + }, + onError: () => showError('Fehler beim Abschlie\u00dfen'), + }); + + // ── Approve ── + const approveMutation = useMutation({ + mutationFn: () => checklistenApi.approveExecution(id!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['checklisten-ausfuehrung', id] }); + queryClient.invalidateQueries({ queryKey: ['checklisten-ausfuehrungen'] }); + showSuccess('Checkliste freigegeben'); + }, + onError: () => showError('Fehler bei der Freigabe'), + }); + + // ── Loading states ── + if (isNew || startingExecution) { + return ( + + + + ); + } + + if (isLoading) { + return ( + + + + ); + } + + if (isError || !execution) { + return ( + + Checkliste konnte nicht geladen werden. + + + ); + } + + const isReadOnly = execution.status === 'abgeschlossen' || execution.status === 'freigegeben'; + const items = execution.items ?? []; + const vorlageItems = items.filter((i) => i.vorlage_item_id != null); + const vehicleItems = items.filter((i) => i.fahrzeug_item_id != null); + + const renderItemGroup = (groupItems: ChecklistAusfuehrungItem[], title: string) => { + if (groupItems.length === 0) return null; + return ( + + {title} + {groupItems.map((item) => { + const result = itemResults[item.id]; + return ( + + + + {item.bezeichnung} + {/* Indicate pflicht with asterisk - we don't have pflicht on ausfuehrung items directly but could check */} + + {isReadOnly && result?.ergebnis && ERGEBNIS_ICONS[result.ergebnis]} + + {isReadOnly ? ( + + + {result?.kommentar && ( + {result.kommentar} + )} + + ) : ( + + setItemResult(item.id, e.target.value as 'ok' | 'nok' | 'na')} + > + } label="OK" /> + } label="Nicht OK" /> + } label="N/A" /> + + setItemComment(item.id, e.target.value)} + sx={{ mt: 0.5 }} + /> + + )} + + ); + })} + + ); + }; + + return ( + + + + + + + {execution.vorlage_name ?? 'Checkliste'} + + + {execution.fahrzeug_name ?? '\u2013'} · {formatDate(execution.ausgefuehrt_am ?? execution.created_at)} + + + + + + {execution.status === 'unvollstaendig' && ( + + Diese Checkliste wurde als unvollst\u00e4ndig abgeschlossen. Einige Pflicht-Items wurden nicht mit "OK" bewertet. + + )} + + + + {renderItemGroup(vorlageItems, 'Vorlage-Items')} + {vorlageItems.length > 0 && vehicleItems.length > 0 && } + {renderItemGroup(vehicleItems, 'Fahrzeugspezifische Items')} + + {items.length === 0 && ( + Keine Items in dieser Checkliste. + )} + + + + {/* Notes */} + + + Notizen + {isReadOnly ? ( + {execution.notizen || 'Keine Notizen'} + ) : ( + setNotizen(e.target.value)} + /> + )} + + + + {/* Actions */} + + {execution.status === 'offen' && ( + + )} + + {canApprove && execution.status === 'abgeschlossen' && ( + + )} + + + {/* Metadata */} + {(execution.ausgefuehrt_von_name || execution.freigegeben_von_name) && ( + + {execution.ausgefuehrt_von_name && ( + + Ausgef\u00fchrt von: {execution.ausgefuehrt_von_name} am {formatDate(execution.ausgefuehrt_am)} + + )} + {execution.freigegeben_von_name && ( + + Freigegeben von: {execution.freigegeben_von_name} am {formatDate(execution.freigegeben_am)} + + )} + + )} + + ); +} diff --git a/frontend/src/pages/Checklisten.tsx b/frontend/src/pages/Checklisten.tsx new file mode 100644 index 0000000..05c0390 --- /dev/null +++ b/frontend/src/pages/Checklisten.tsx @@ -0,0 +1,649 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormControlLabel, + IconButton, + InputLabel, + MenuItem, + Paper, + Select, + Switch, + Tab, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tabs, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { + Add as AddIcon, + CheckCircle, + Delete as DeleteIcon, + Edit as EditIcon, + PlayArrow, + Schedule, + Warning, +} from '@mui/icons-material'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { usePermissionContext } from '../contexts/PermissionContext'; +import { useNotification } from '../contexts/NotificationContext'; +import { checklistenApi } from '../services/checklisten'; +import { fahrzeugTypenApi } from '../services/fahrzeugTypen'; +import { vehiclesApi } from '../services/vehicles'; +import { + CHECKLIST_STATUS_LABELS, + CHECKLIST_STATUS_COLORS, +} from '../types/checklist.types'; +import type { + ChecklistVorlage, + ChecklistAusfuehrung, + ChecklistFaelligkeit, + FahrzeugTyp, + CreateVorlagePayload, + UpdateVorlagePayload, + CreateVorlageItemPayload, +} from '../types/checklist.types'; + +// ── Helpers ── + +const formatDate = (iso?: string) => + iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '\u2013'; + +const INTERVALL_LABELS: Record = { + weekly: 'W\u00f6chentlich', + monthly: 'Monatlich', + yearly: 'J\u00e4hrlich', + custom: 'Benutzerdefiniert', +}; + +// ── Tab Panel ── + +interface TabPanelProps { children: React.ReactNode; index: number; value: number } +function TabPanel({ children, value, index }: TabPanelProps) { + if (value !== index) return null; + return {children}; +} + +// ══════════════════════════════════════════════════════════════════════════════ +// Component +// ══════════════════════════════════════════════════════════════════════════════ + +export default function Checklisten() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const queryClient = useQueryClient(); + const { hasPermission } = usePermissionContext(); + const { showSuccess, showError } = useNotification(); + + const canManageTemplates = hasPermission('checklisten:manage_templates'); + const canExecute = hasPermission('checklisten:execute'); + + // Tabs: 0=\u00dcbersicht, 1=Vorlagen (if perm), 2=Fahrzeugtypen (if perm), 3=Historie + const manageTabs = canManageTemplates ? 2 : 0; + const TAB_COUNT = 2 + manageTabs; + + const [tab, setTab] = useState(() => { + const t = Number(searchParams.get('tab')); + return t >= 0 && t < TAB_COUNT ? t : 0; + }); + useEffect(() => { + const t = Number(searchParams.get('tab')); + if (t >= 0 && t < TAB_COUNT) setTab(t); + }, [searchParams, TAB_COUNT]); + + // ── Queries ── + const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ + queryKey: ['vehicles', 'checklisten-overview'], + queryFn: () => vehiclesApi.getAll(), + }); + + const { data: overdue = [] } = useQuery({ + queryKey: ['checklisten-faellig'], + queryFn: checklistenApi.getOverdue, + refetchInterval: 5 * 60 * 1000, + }); + + const { data: vorlagen = [], isLoading: vorlagenLoading } = useQuery({ + queryKey: ['checklisten-vorlagen'], + queryFn: () => checklistenApi.getVorlagen(), + enabled: canManageTemplates, + }); + + const { data: fahrzeugTypen = [] } = useQuery({ + queryKey: ['fahrzeug-typen'], + queryFn: fahrzeugTypenApi.getAll, + enabled: canManageTemplates, + }); + + const { data: executions = [], isLoading: executionsLoading } = useQuery({ + queryKey: ['checklisten-ausfuehrungen'], + queryFn: () => checklistenApi.getExecutions(), + }); + + // Build overdue lookup: fahrzeugId -> ChecklistFaelligkeit[] + const overdueByVehicle = overdue.reduce>((acc, f) => { + if (!acc[f.fahrzeug_id]) acc[f.fahrzeug_id] = []; + acc[f.fahrzeug_id].push(f); + return acc; + }, {}); + + // ── Tab indices ── + const vorlagenTabIdx = canManageTemplates ? 1 : -1; + const typenTabIdx = canManageTemplates ? 2 : -1; + const historieTabIdx = canManageTemplates ? 3 : 1; + + return ( + + Checklisten + + + { setTab(v); navigate(`/checklisten?tab=${v}`, { replace: true }); }} + variant="scrollable" + scrollButtons="auto" + > + + {canManageTemplates && } + {canManageTemplates && } + + + + + {/* Tab 0: \u00dcbersicht */} + + {vehiclesLoading ? ( + + ) : vehicles.length === 0 ? ( + Keine Fahrzeuge vorhanden. + ) : ( + + {vehicles.map((v) => { + const vOverdue = overdueByVehicle[v.id] || []; + return ( + + + + {v.bezeichnung ?? v.kurzname} + {vOverdue.length > 0 && ( + } + label={`${vOverdue.length} f\u00e4llig`} + color="error" + size="small" + /> + )} + + {vOverdue.length > 0 ? ( + vOverdue.map((f) => { + const days = Math.ceil((Date.now() - new Date(f.naechste_faellig_am).getTime()) / 86400000); + return ( + + + + {f.vorlage_name} + + ({days > 0 ? `${days}d \u00fcberf\u00e4llig` : 'heute f\u00e4llig'}) + + + {canExecute && ( + + navigate(`/checklisten/ausfuehrung/new?fahrzeug=${v.id}&vorlage=${f.vorlage_id}`)} + > + + + + )} + + ); + }) + ) : ( + + + Alle Checklisten aktuell + + )} + + + ); + })} + + )} + + + {/* Tab 1: Vorlagen (templates) */} + {canManageTemplates && ( + + + + )} + + {/* Tab 2: Fahrzeugtypen */} + {canManageTemplates && ( + + + + )} + + {/* Tab 3: Historie */} + + + + + ); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// Vorlagen Tab +// ══════════════════════════════════════════════════════════════════════════════ + +interface VorlagenTabProps { + vorlagen: ChecklistVorlage[]; + loading: boolean; + fahrzeugTypen: FahrzeugTyp[]; + queryClient: ReturnType; + showSuccess: (msg: string) => void; + showError: (msg: string) => void; +} + +function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSuccess, showError }: VorlagenTabProps) { + const [dialogOpen, setDialogOpen] = useState(false); + const [editingVorlage, setEditingVorlage] = useState(null); + const [expandedVorlageId, setExpandedVorlageId] = useState(null); + + const emptyForm: CreateVorlagePayload = { name: '', fahrzeug_typ_id: undefined, intervall: undefined, intervall_tage: undefined, beschreibung: '', aktiv: true }; + const [form, setForm] = useState(emptyForm); + + const createMutation = useMutation({ + mutationFn: (data: CreateVorlagePayload) => checklistenApi.createVorlage(data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['checklisten-vorlagen'] }); setDialogOpen(false); showSuccess('Vorlage erstellt'); }, + onError: () => showError('Fehler beim Erstellen der Vorlage'), + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: UpdateVorlagePayload }) => checklistenApi.updateVorlage(id, data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['checklisten-vorlagen'] }); setDialogOpen(false); showSuccess('Vorlage aktualisiert'); }, + onError: () => showError('Fehler beim Aktualisieren der Vorlage'), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => checklistenApi.deleteVorlage(id), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['checklisten-vorlagen'] }); showSuccess('Vorlage gel\u00f6scht'); }, + onError: () => showError('Fehler beim L\u00f6schen der Vorlage'), + }); + + const openCreate = () => { setEditingVorlage(null); setForm(emptyForm); setDialogOpen(true); }; + const openEdit = (v: ChecklistVorlage) => { + setEditingVorlage(v); + setForm({ name: v.name, fahrzeug_typ_id: v.fahrzeug_typ_id, intervall: v.intervall, intervall_tage: v.intervall_tage, beschreibung: v.beschreibung ?? '', aktiv: v.aktiv }); + setDialogOpen(true); + }; + + const handleSubmit = () => { + if (!form.name.trim()) return; + if (editingVorlage) { + updateMutation.mutate({ id: editingVorlage.id, data: form }); + } else { + createMutation.mutate(form); + } + }; + + const isSaving = createMutation.isPending || updateMutation.isPending; + + if (loading) return ; + + return ( + + + + + + + + + + Name + Fahrzeugtyp + Intervall + Aktiv + Aktionen + + + + {vorlagen.length === 0 ? ( + Keine Vorlagen vorhanden + ) : ( + vorlagen.map((v) => ( + + setExpandedVorlageId(expandedVorlageId === v.id ? null : v.id)}> + {v.name} + {v.fahrzeug_typ?.name ?? '\u2013'} + + {v.intervall ? INTERVALL_LABELS[v.intervall] || v.intervall : '\u2013'} + {v.intervall === 'custom' && v.intervall_tage ? ` (${v.intervall_tage} Tage)` : ''} + + + + + + { e.stopPropagation(); openEdit(v); }}> + { e.stopPropagation(); deleteMutation.mutate(v.id); }}> + + + {expandedVorlageId === v.id && ( + + + + + + )} + + )) + )} + +
+
+ + {/* Create/Edit Dialog */} + setDialogOpen(false)} maxWidth="sm" fullWidth> + {editingVorlage ? 'Vorlage bearbeiten' : 'Neue Vorlage'} + + setForm((f) => ({ ...f, name: e.target.value }))} /> + + Fahrzeugtyp + + + + Intervall + + + {form.intervall === 'custom' && ( + setForm((f) => ({ ...f, intervall_tage: e.target.value ? Number(e.target.value) : undefined }))} inputProps={{ min: 1 }} /> + )} + setForm((f) => ({ ...f, beschreibung: e.target.value }))} /> + setForm((f) => ({ ...f, aktiv: e.target.checked }))} />} label="Aktiv" /> + + + + + + +
+ ); +} + +// ── Vorlage Items Sub-section ── + +function VorlageItemsSection({ vorlageId, queryClient, showSuccess, showError }: { vorlageId: number; queryClient: ReturnType; showSuccess: (msg: string) => void; showError: (msg: string) => void }) { + const { data: items = [], isLoading } = useQuery({ + queryKey: ['checklisten-vorlage-items', vorlageId], + queryFn: () => checklistenApi.getVorlageItems(vorlageId), + }); + + const [newItem, setNewItem] = useState({ bezeichnung: '', pflicht: false, sort_order: 0 }); + + const addMutation = useMutation({ + mutationFn: (data: CreateVorlageItemPayload) => checklistenApi.addVorlageItem(vorlageId, data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['checklisten-vorlage-items', vorlageId] }); setNewItem({ bezeichnung: '', pflicht: false, sort_order: 0 }); showSuccess('Item hinzugef\u00fcgt'); }, + onError: () => showError('Fehler beim Hinzuf\u00fcgen'), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => checklistenApi.deleteVorlageItem(id), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['checklisten-vorlage-items', vorlageId] }); showSuccess('Item entfernt'); }, + onError: () => showError('Fehler beim Entfernen'), + }); + + if (isLoading) return ; + + return ( + + Checklisten-Items + {items.map((item) => ( + + + {item.bezeichnung} {item.pflicht && } + + deleteMutation.mutate(item.id)}> + + ))} + + setNewItem((n) => ({ ...n, bezeichnung: e.target.value }))} sx={{ flexGrow: 1 }} /> + setNewItem((n) => ({ ...n, pflicht: e.target.checked }))} />} label="Pflicht" /> + + + + ); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// Fahrzeugtypen Tab +// ══════════════════════════════════════════════════════════════════════════════ + +interface FahrzeugTypenTabProps { + fahrzeugTypen: FahrzeugTyp[]; + queryClient: ReturnType; + showSuccess: (msg: string) => void; + showError: (msg: string) => void; +} + +function FahrzeugTypenTab({ fahrzeugTypen, queryClient, showSuccess, showError }: FahrzeugTypenTabProps) { + const [dialogOpen, setDialogOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState({ name: '', beschreibung: '', icon: '' }); + + 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'] }); showSuccess('Fahrzeugtyp gel\u00f6scht'); }, + onError: () => showError('Fehler beim L\u00f6schen'), + }); + + 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; + + return ( + + + + + + + + + + Name + Beschreibung + Icon + Aktionen + + + + {fahrzeugTypen.length === 0 ? ( + Keine Fahrzeugtypen vorhanden + ) : ( + fahrzeugTypen.map((t) => ( + + {t.name} + {t.beschreibung ?? '\u2013'} + {t.icon ?? '\u2013'} + + 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" /> + + + + + + +
+ ); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// Historie Tab +// ══════════════════════════════════════════════════════════════════════════════ + +interface HistorieTabProps { + executions: ChecklistAusfuehrung[]; + loading: boolean; + navigate: ReturnType; +} + +function HistorieTab({ executions, loading, navigate }: HistorieTabProps) { + const [statusFilter, setStatusFilter] = useState(''); + const [vehicleFilter, setVehicleFilter] = useState(''); + + const filtered = executions.filter((e) => { + if (statusFilter && e.status !== statusFilter) return false; + if (vehicleFilter && e.fahrzeug_name !== vehicleFilter) return false; + return true; + }); + + const uniqueVehicles = [...new Set(executions.map((e) => e.fahrzeug_name).filter(Boolean))] as string[]; + + if (loading) return ; + + return ( + + + + Status + + + + Fahrzeug + + + + + + + + + Fahrzeug + Vorlage + Datum + Status + Ausgef\u00fchrt von + Freigegeben von + + + + {filtered.length === 0 ? ( + Keine Eintr\u00e4ge + ) : ( + filtered.map((e) => ( + navigate(`/checklisten/ausfuehrung/${e.id}`)}> + {e.fahrzeug_name ?? '\u2013'} + {e.vorlage_name ?? '\u2013'} + {formatDate(e.ausgefuehrt_am ?? e.created_at)} + + + + {e.ausgefuehrt_von_name ?? '\u2013'} + {e.freigegeben_von_name ?? '\u2013'} + + )) + )} + +
+
+
+ ); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 57a70ab..f048f2e 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,10 +1,26 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; import { Container, Box, Fade, + IconButton, + Tooltip, } from '@mui/material'; -import { useQuery } from '@tanstack/react-query'; +import { Edit as EditIcon, Check as CheckIcon } from '@mui/icons-material'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { + DndContext, + PointerSensor, + useSensor, + useSensors, + closestCenter, +} from '@dnd-kit/core'; +import type { DragEndEvent } from '@dnd-kit/core'; +import { + SortableContext, + rectSortingStrategy, + arrayMove, +} from '@dnd-kit/sortable'; import { useAuth } from '../contexts/AuthContext'; import { usePermissionContext } from '../contexts/PermissionContext'; import DashboardLayout from '../components/dashboard/DashboardLayout'; @@ -28,18 +44,52 @@ import EventQuickAddWidget from '../components/dashboard/EventQuickAddWidget'; import LinksWidget from '../components/dashboard/LinksWidget'; import BannerWidget from '../components/dashboard/BannerWidget'; import WidgetGroup from '../components/dashboard/WidgetGroup'; +import SortableWidget from '../components/dashboard/SortableWidget'; import BestellungenWidget from '../components/dashboard/BestellungenWidget'; import AusruestungsanfrageWidget from '../components/dashboard/AusruestungsanfrageWidget'; import IssueQuickAddWidget from '../components/dashboard/IssueQuickAddWidget'; import IssueOverviewWidget from '../components/dashboard/IssueOverviewWidget'; +import ChecklistWidget from '../components/dashboard/ChecklistWidget'; import { preferencesApi } from '../services/settings'; import { configApi } from '../services/config'; import { WidgetKey } from '../constants/widgets'; +// ── Widget definitions per group ── + +interface WidgetDef { + key: string; + widgetKey?: WidgetKey; + permission?: string; + component: React.ReactNode; +} + +type GroupName = 'status' | 'kalender' | 'dienste' | 'information'; + +const GROUP_ORDER: { name: GroupName; title: string }[] = [ + { name: 'status', title: 'Status' }, + { name: 'kalender', title: 'Kalender' }, + { name: 'dienste', title: 'Dienste' }, + { name: 'information', title: 'Information' }, +]; + +// Default widget order per group (used when no preference is set) +const DEFAULT_ORDER: Record = { + status: ['vehicles', 'equipment', 'atemschutz', 'adminStatus', 'bestellungen', 'ausruestungsanfragen', 'issueOverview', 'checklistOverdue'], + kalender: ['events', 'vehicleBookingList', 'vehicleBooking', 'eventQuickAdd'], + dienste: ['bookstackRecent', 'bookstackSearch', 'vikunjaTasks', 'vikunjaQuickAdd', 'issueQuickAdd'], + information: ['links', 'bannerWidget'], +}; + function Dashboard() { const { user } = useAuth(); const { hasPermission } = usePermissionContext(); + const queryClient = useQueryClient(); const [dataLoading, setDataLoading] = useState(true); + const [editMode, setEditMode] = useState(false); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + ); const { data: preferences } = useQuery({ queryKey: ['user-preferences'], @@ -60,203 +110,214 @@ function Dashboard() { return preferences?.widgets?.[key] !== false; }; + // Build widget definitions for each group + const widgetDefs: Record = useMemo(() => ({ + status: [ + { key: 'vehicles', widgetKey: 'vehicles', permission: 'fahrzeuge:widget', component: }, + { key: 'equipment', widgetKey: 'equipment', permission: 'ausruestung:widget', component: }, + { key: 'atemschutz', widgetKey: 'atemschutz', permission: 'atemschutz:widget', component: }, + { key: 'adminStatus', widgetKey: 'adminStatus', permission: 'admin:view', component: }, + { key: 'bestellungen', widgetKey: 'bestellungen', permission: 'bestellungen:widget', component: }, + { key: 'ausruestungsanfragen', widgetKey: 'ausruestungsanfragen', permission: 'ausruestungsanfrage:widget', component: }, + { key: 'issueOverview', widgetKey: 'issueOverview', permission: 'issues:view_all', component: }, + { key: 'checklistOverdue', widgetKey: 'checklistOverdue', permission: 'checklisten:widget', component: }, + ], + kalender: [ + { key: 'events', widgetKey: 'events', permission: 'kalender:view', component: }, + { key: 'vehicleBookingList', widgetKey: 'vehicleBookingList', permission: 'fahrzeugbuchungen:view', component: }, + { key: 'vehicleBooking', widgetKey: 'vehicleBooking', permission: 'fahrzeugbuchungen:manage', component: }, + { key: 'eventQuickAdd', widgetKey: 'eventQuickAdd', permission: 'kalender:create', component: }, + ], + dienste: [ + { key: 'bookstackRecent', widgetKey: 'bookstackRecent', permission: 'wissen:view', component: }, + { key: 'bookstackSearch', widgetKey: 'bookstackSearch', permission: 'wissen:view', component: }, + { key: 'vikunjaTasks', widgetKey: 'vikunjaTasks', permission: 'vikunja:widget_tasks', component: }, + { key: 'vikunjaQuickAdd', widgetKey: 'vikunjaQuickAdd', permission: 'vikunja:widget_quick_add', component: }, + { key: 'issueQuickAdd', widgetKey: 'issueQuickAdd', permission: 'issues:widget', component: }, + ], + information: [ + // Links are handled specially (dynamic collections), but we keep a placeholder + { key: 'links', widgetKey: 'links', permission: 'dashboard:widget_links', component: null }, + { key: 'bannerWidget', permission: 'dashboard:widget_banner', component: }, + ], + }), []); + + // Widget order from preferences, falling back to defaults + const [localOrder, setLocalOrder] = useState>(DEFAULT_ORDER); + + useEffect(() => { + if (preferences?.widgetOrder) { + setLocalOrder((prev) => { + const merged = { ...prev }; + for (const group of Object.keys(DEFAULT_ORDER) as GroupName[]) { + if (preferences.widgetOrder[group]) { + // Merge: saved order first, then any new widgets not in saved order + const saved = preferences.widgetOrder[group] as string[]; + const allKeys = DEFAULT_ORDER[group]; + const ordered = saved.filter((k: string) => allKeys.includes(k)); + const remaining = allKeys.filter((k) => !ordered.includes(k)); + merged[group] = [...ordered, ...remaining]; + } + } + return merged; + }); + } + }, [preferences?.widgetOrder]); + + // Get sorted + filtered widgets for a group + const getVisibleWidgets = useCallback((group: GroupName) => { + const order = localOrder[group]; + const defs = widgetDefs[group]; + return order + .map((key) => defs.find((d) => d.key === key)) + .filter((d): d is WidgetDef => { + if (!d) return false; + if (d.permission && !hasPermission(d.permission)) return false; + if (d.widgetKey && !widgetVisible(d.widgetKey)) return false; + return true; + }); + }, [localOrder, widgetDefs, hasPermission, preferences]); + + const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + + // Find which group both items belong to + for (const group of Object.keys(localOrder) as GroupName[]) { + const order = localOrder[group]; + const oldIndex = order.indexOf(active.id as string); + const newIndex = order.indexOf(over.id as string); + if (oldIndex !== -1 && newIndex !== -1) { + const newOrder = arrayMove(order, oldIndex, newIndex); + setLocalOrder((prev) => ({ ...prev, [group]: newOrder })); + // Persist + const updatedOrder = { ...localOrder, [group]: newOrder }; + preferencesApi.update({ widgetOrder: updatedOrder }).then(() => { + queryClient.invalidateQueries({ queryKey: ['user-preferences'] }); + }); + break; + } + } + }, [localOrder, queryClient]); + useEffect(() => { const timer = setTimeout(() => { setDataLoading(false); }, 800); - return () => clearTimeout(timer); }, []); + const baseDelay = 300; + let delayCounter = 0; + const nextDelay = () => `${baseDelay + (delayCounter++) * 40}ms`; + + // Render a group's widgets + const renderGroup = (group: GroupName) => { + const visible = getVisibleWidgets(group); + const keys = visible.map((d) => d.key); + + // Special handling for information group (links are dynamic) + if (group === 'information') { + const linksVisible = visible.some((d) => d.key === 'links'); + const bannerVisible = visible.some((d) => d.key === 'bannerWidget'); + const hasContent = (linksVisible && linkCollections.length > 0) || bannerVisible; + if (!hasContent) return null; + + return ( + + {linksVisible && linkCollections.map((collection) => ( + + + + + + ))} + {bannerVisible && ( + + + + + + )} + + ); + } + + if (keys.length === 0) return null; + + return ( + g.name === group)!.title} + gridColumn="1 / -1" + key={group} + > + + {visible.map((def) => { + const delay = nextDelay(); + return ( + + + {def.component} + + + ); + })} + + + ); + }; + return ( - {/* Vikunja — Overdue Notifier (invisible, polling component — outside grid) */} - {/* Atemschutz — Expiry Notifier (invisible, polling component — outside grid) */} + + {/* Edit mode toggle */} + + + setEditMode((prev) => !prev)} + color={editMode ? 'primary' : 'default'} + > + {editMode ? : } + + + + - - {/* Announcement Banner — spans full width, renders null when no banners */} - + + - {/* User Profile Card — full width, contains welcome greeting */} - {user && ( - - {dataLoading ? ( - - ) : ( - - - - - - )} - - )} - - {/* Status Group */} - - {hasPermission('fahrzeuge:widget') && widgetVisible('vehicles') && ( - - - - - + {user && ( + + {dataLoading ? ( + + ) : ( + + + + + + )} + )} - {hasPermission('ausruestung:widget') && widgetVisible('equipment') && ( - - - - - - )} - - {hasPermission('atemschutz:widget') && widgetVisible('atemschutz') && ( - - - - - - )} - - {hasPermission('admin:view') && widgetVisible('adminStatus') && ( - - - - - - )} - - {hasPermission('bestellungen:widget') && widgetVisible('bestellungen') && ( - - - - - - )} - - {hasPermission('ausruestungsanfrage:widget') && widgetVisible('ausruestungsanfragen') && ( - - - - - - )} - - {hasPermission('issues:view_all') && widgetVisible('issueOverview') && ( - - - - - - )} - - - {/* Kalender Group */} - - {hasPermission('kalender:view') && widgetVisible('events') && ( - - - - - - )} - - {hasPermission('fahrzeugbuchungen:view') && widgetVisible('vehicleBookingList') && ( - - - - - - )} - - {hasPermission('fahrzeugbuchungen:manage') && widgetVisible('vehicleBooking') && ( - - - - - - )} - - {hasPermission('kalender:create') && widgetVisible('eventQuickAdd') && ( - - - - - - )} - - - {/* Dienste Group */} - - {hasPermission('wissen:view') && widgetVisible('bookstackRecent') && ( - - - - - - )} - - {hasPermission('wissen:view') && widgetVisible('bookstackSearch') && ( - - - - - - )} - - {hasPermission('vikunja:widget_tasks') && widgetVisible('vikunjaTasks') && ( - - - - - - )} - - {hasPermission('vikunja:widget_quick_add') && widgetVisible('vikunjaQuickAdd') && ( - - - - - - )} - - {hasPermission('issues:widget') && widgetVisible('issueQuickAdd') && ( - - - - - - )} - - - {/* Information Group */} - - {hasPermission('dashboard:widget_links') && widgetVisible('links') && linkCollections.map((collection, idx) => ( - - - - - - ))} - - {hasPermission('dashboard:widget_banner') && ( - - - - - - )} - - + {GROUP_ORDER.map((g) => renderGroup(g.name))} + + ); diff --git a/frontend/src/pages/FahrzeugDetail.tsx b/frontend/src/pages/FahrzeugDetail.tsx index c3cc3dd..9dd7449 100644 --- a/frontend/src/pages/FahrzeugDetail.tsx +++ b/frontend/src/pages/FahrzeugDetail.tsx @@ -77,7 +77,9 @@ import { import type { AusruestungListItem } from '../types/equipment.types'; import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types'; import { usePermissions } from '../hooks/usePermissions'; +import { usePermissionContext } from '../contexts/PermissionContext'; import { useNotification } from '../contexts/NotificationContext'; +import FahrzeugChecklistTab from '../components/fahrzeuge/FahrzeugChecklistTab'; // ── Tab Panel ───────────────────────────────────────────────────────────────── @@ -880,6 +882,7 @@ function FahrzeugDetail() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { isAdmin, canChangeStatus, canManageMaintenance } = usePermissions(); + const { hasPermission } = usePermissionContext(); const notification = useNotification(); const [vehicle, setVehicle] = useState(null); @@ -1035,6 +1038,7 @@ function FahrzeugDetail() { /> 0 ? ` (${vehicleEquipment.length})` : ''}`} /> + {hasPermission('checklisten:view') && }
@@ -1071,6 +1075,12 @@ function FahrzeugDetail() { + {hasPermission('checklisten:view') && ( + + + + )} + {/* Delete confirmation dialog */} !deleteLoading && setDeleteDialogOpen(false)}> Fahrzeug löschen diff --git a/frontend/src/pages/IssueDetail.tsx b/frontend/src/pages/IssueDetail.tsx index 88a9077..8254719 100644 --- a/frontend/src/pages/IssueDetail.tsx +++ b/frontend/src/pages/IssueDetail.tsx @@ -3,11 +3,14 @@ import { Box, Typography, Paper, Chip, IconButton, Button, Dialog, DialogTitle, DialogContent, DialogActions, TextField, MenuItem, Select, FormControl, InputLabel, Divider, CircularProgress, Autocomplete, Grid, Card, CardContent, + List, ListItem, ListItemIcon, ListItemText, ListItemSecondaryAction, } from '@mui/material'; import { ArrowBack, Delete as DeleteIcon, BugReport, FiberNew, HelpOutline, Send as SendIcon, Circle as CircleIcon, Refresh as RefreshIcon, History, + AttachFile as AttachFileIcon, InsertDriveFile as FileIcon, + Upload as UploadIcon, } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useNavigate } from 'react-router-dom'; @@ -16,13 +19,29 @@ import { useNotification } from '../contexts/NotificationContext'; import { usePermissionContext } from '../contexts/PermissionContext'; import { useAuth } from '../contexts/AuthContext'; import { issuesApi } from '../services/issues'; -import type { Issue, IssueComment, UpdateIssuePayload, AssignableMember, IssueStatusDef, IssuePriorityDef, IssueHistorie } from '../types/issue.types'; +import type { Issue, IssueComment, UpdateIssuePayload, AssignableMember, IssueStatusDef, IssuePriorityDef, IssueHistorie, IssueDatei } from '../types/issue.types'; // ── Helpers (copied from Issues.tsx) ── const formatDate = (iso?: string) => iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-'; +const formatDateOnly = (iso?: string | null) => + iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '-'; + +const toInputDate = (iso?: string | null) => { + if (!iso) return ''; + const d = new Date(iso); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +}; + +const formatFileSize = (bytes: number | null) => { + if (!bytes) return ''; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}; + const formatIssueId = (issue: Issue) => `${new Date(issue.created_at).getFullYear()}/${issue.id}`; @@ -123,6 +142,12 @@ export default function IssueDetail() { enabled: !isNaN(issueId), }); + const { data: files = [] } = useQuery({ + queryKey: ['issues', issueId, 'files'], + queryFn: () => issuesApi.getFiles(issueId), + enabled: !isNaN(issueId), + }); + // ── Permissions ── const isOwner = issue?.erstellt_von === userId; const isAssignee = issue?.zugewiesen_an === userId; @@ -175,6 +200,30 @@ export default function IssueDetail() { onError: () => showError('Kommentar konnte nicht erstellt werden'), }); + const uploadFileMut = useMutation({ + mutationFn: (file: File) => issuesApi.uploadFile(issueId, file), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['issues', issueId, 'files'] }); + showSuccess('Datei hochgeladen'); + }, + onError: () => showError('Datei konnte nicht hochgeladen werden'), + }); + + const deleteFileMut = useMutation({ + mutationFn: (fileId: string) => issuesApi.deleteFile(fileId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['issues', issueId, 'files'] }); + showSuccess('Datei gelöscht'); + }, + onError: () => showError('Datei konnte nicht gelöscht werden'), + }); + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) uploadFileMut.mutate(file); + e.target.value = ''; + }; + const handleReopen = () => { updateMut.mutate({ status: initialStatusKey, kommentar: reopenComment.trim() }, { onSuccess: () => { @@ -275,6 +324,20 @@ export default function IssueDetail() { + + + + Fällig am + + {formatDateOnly(issue.faellig_am)} + + + + {/* Description */} @@ -285,6 +348,57 @@ export default function IssueDetail() { )} + {/* Attachments */} + + + + + Anhänge ({files.length}) + + {canComment && ( + + )} + + {files.length === 0 ? ( + Keine Anhänge + ) : ( + + {files.map((f) => ( + + + + + + + {(hasEdit || isOwner) && ( + deleteFileMut.mutate(f.id)} + disabled={deleteFileMut.isPending} + > + + + )} + + + ))} + + )} + + {/* Controls row */} {/* Status control */} @@ -341,6 +455,19 @@ export default function IssueDetail() { isOptionEqualToValue={(o, v) => o.id === v.id} /> )} + + {/* Due date */} + {hasEdit && ( + updateMut.mutate({ faellig_am: e.target.value || null })} + InputLabelProps={{ shrink: true }} + sx={{ minWidth: 160 }} + /> + )} {/* Delete button */} diff --git a/frontend/src/pages/IssueNeu.tsx b/frontend/src/pages/IssueNeu.tsx index feebc5f..97d6417 100644 --- a/frontend/src/pages/IssueNeu.tsx +++ b/frontend/src/pages/IssueNeu.tsx @@ -121,6 +121,16 @@ export default function IssueNeu() { + + setForm({ ...form, faellig_am: e.target.value || null })} + InputLabelProps={{ shrink: true }} + /> + diff --git a/frontend/src/pages/Issues.tsx b/frontend/src/pages/Issues.tsx index 6118e34..06370ff 100644 --- a/frontend/src/pages/Issues.tsx +++ b/frontend/src/pages/Issues.tsx @@ -4,13 +4,14 @@ import { TableHead, TableRow, Paper, Chip, IconButton, Button, Dialog, DialogTitle, DialogContent, DialogActions, TextField, MenuItem, Select, FormControl, InputLabel, CircularProgress, FormControlLabel, Switch, - Autocomplete, + Autocomplete, ToggleButtonGroup, ToggleButton, } from '@mui/material'; import { Add as AddIcon, Delete as DeleteIcon, BugReport, FiberNew, HelpOutline, Circle as CircleIcon, Edit as EditIcon, DragIndicator, Check as CheckIcon, Close as CloseIcon, + ViewList as ViewListIcon, ViewKanban as ViewKanbanIcon, } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useSearchParams, useNavigate } from 'react-router-dom'; @@ -21,6 +22,7 @@ import { usePermissionContext } from '../contexts/PermissionContext'; import { useAuth } from '../contexts/AuthContext'; import { issuesApi } from '../services/issues'; import type { Issue, IssueTyp, IssueFilters, AssignableMember, IssueStatusDef, IssuePriorityDef } from '../types/issue.types'; +import KanbanBoard from '../components/issues/KanbanBoard'; // ── Helpers ── @@ -555,8 +557,10 @@ function IssueSettings() { export default function Issues() { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const { hasPermission } = usePermissionContext(); const { user } = useAuth(); + const { showSuccess, showError } = useNotification(); const canViewAll = hasPermission('issues:view_all'); const hasEdit = hasPermission('issues:edit'); @@ -581,6 +585,27 @@ export default function Issues() { const [showDoneMine, setShowDoneMine] = useState(false); const [showDoneAssigned, setShowDoneAssigned] = useState(false); const [filters, setFilters] = useState({}); + const [viewMode, setViewMode] = useState<'list' | 'kanban'>(() => { + try { return (localStorage.getItem('issues-view-mode') as 'list' | 'kanban') || 'list'; } + catch { return 'list'; } + }); + + const handleViewModeChange = (_: unknown, val: 'list' | 'kanban' | null) => { + if (!val) return; + setViewMode(val); + localStorage.setItem('issues-view-mode', val); + }; + + // Mutation for kanban drag-and-drop status change + const updateStatusMut = useMutation({ + mutationFn: ({ id, status }: { id: number; status: string }) => + issuesApi.updateIssue(id, { status }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['issues'] }); + showSuccess('Status aktualisiert'); + }, + onError: () => showError('Fehler beim Aktualisieren'), + }); // Fetch all issues for mine/assigned tabs const { data: issues = [], isLoading } = useQuery({ @@ -634,7 +659,22 @@ export default function Issues() { return ( - Issues + + Issues + + + + + + + + + {tabs.map((t, i) => )} @@ -642,13 +682,24 @@ export default function Issues() { {/* Tab 0: Meine Issues */} - setShowDoneMine(e.target.checked)} size="small" />} - label="Erledigte anzeigen" - sx={{ mb: 1 }} - /> + + {viewMode === 'list' && ( + setShowDoneMine(e.target.checked)} size="small" />} + label="Erledigte anzeigen" + /> + )} + {isLoading ? ( + ) : viewMode === 'kanban' ? ( + navigate(`/issues/${id}`)} + onStatusChange={(id, status) => updateStatusMut.mutate({ id, status })} + /> ) : ( )} @@ -656,13 +707,24 @@ export default function Issues() { {/* Tab 1: Zugewiesene Issues */} - setShowDoneAssigned(e.target.checked)} size="small" />} - label="Erledigte anzeigen" - sx={{ mb: 1 }} - /> + + {viewMode === 'list' && ( + setShowDoneAssigned(e.target.checked)} size="small" />} + label="Erledigte anzeigen" + /> + )} + {isLoading ? ( + ) : viewMode === 'kanban' ? ( + navigate(`/issues/${id}`)} + onStatusChange={(id, status) => updateStatusMut.mutate({ id, status })} + /> ) : ( )} @@ -674,6 +736,14 @@ export default function Issues() { {isFilteredLoading ? ( + ) : viewMode === 'kanban' ? ( + navigate(`/issues/${id}`)} + onStatusChange={(id, status) => updateStatusMut.mutate({ id, status })} + /> ) : ( )} diff --git a/frontend/src/services/checklisten.ts b/frontend/src/services/checklisten.ts new file mode 100644 index 0000000..3ddafa3 --- /dev/null +++ b/frontend/src/services/checklisten.ts @@ -0,0 +1,131 @@ +import { api } from './api'; +import type { + ChecklistVorlage, + ChecklistVorlageItem, + FahrzeugChecklistItem, + ChecklistAusfuehrung, + ChecklistFaelligkeit, + ChecklistVorlageFilter, + ChecklistAusfuehrungFilter, + CreateVorlagePayload, + UpdateVorlagePayload, + CreateVorlageItemPayload, + UpdateVorlageItemPayload, + CreateFahrzeugItemPayload, + UpdateFahrzeugItemPayload, + SubmitAusfuehrungPayload, +} from '../types/checklist.types'; + +export const checklistenApi = { + // ── Vorlagen (Templates) ── + getVorlagen: async (filter?: ChecklistVorlageFilter): Promise => { + const params = new URLSearchParams(); + if (filter?.fahrzeug_typ_id != null) params.set('fahrzeug_typ_id', String(filter.fahrzeug_typ_id)); + if (filter?.aktiv != null) params.set('aktiv', String(filter.aktiv)); + const qs = params.toString(); + const r = await api.get(`/api/checklisten/vorlagen${qs ? `?${qs}` : ''}`); + return r.data.data; + }, + + getVorlage: async (id: number): Promise => { + const r = await api.get(`/api/checklisten/vorlagen/${id}`); + return r.data.data; + }, + + createVorlage: async (data: CreateVorlagePayload): Promise => { + const r = await api.post('/api/checklisten/vorlagen', data); + return r.data.data; + }, + + updateVorlage: async (id: number, data: UpdateVorlagePayload): Promise => { + const r = await api.put(`/api/checklisten/vorlagen/${id}`, data); + return r.data.data; + }, + + deleteVorlage: async (id: number): Promise => { + await api.delete(`/api/checklisten/vorlagen/${id}`); + }, + + // ── Vorlage Items ── + getVorlageItems: async (vorlageId: number): Promise => { + const r = await api.get(`/api/checklisten/vorlagen/${vorlageId}/items`); + return r.data.data; + }, + + addVorlageItem: async (vorlageId: number, data: CreateVorlageItemPayload): Promise => { + const r = await api.post(`/api/checklisten/vorlagen/${vorlageId}/items`, data); + return r.data.data; + }, + + updateVorlageItem: async (id: number, data: UpdateVorlageItemPayload): Promise => { + const r = await api.put(`/api/checklisten/items/${id}`, data); + return r.data.data; + }, + + deleteVorlageItem: async (id: number): Promise => { + await api.delete(`/api/checklisten/items/${id}`); + }, + + // ── Vehicle-specific Items ── + getVehicleItems: async (fahrzeugId: string): Promise => { + const r = await api.get(`/api/checklisten/fahrzeug/${fahrzeugId}/items`); + return r.data.data; + }, + + addVehicleItem: async (fahrzeugId: string, data: CreateFahrzeugItemPayload): Promise => { + const r = await api.post(`/api/checklisten/fahrzeug/${fahrzeugId}/items`, data); + return r.data.data; + }, + + updateVehicleItem: async (id: number, data: UpdateFahrzeugItemPayload): Promise => { + const r = await api.put(`/api/checklisten/fahrzeug-items/${id}`, data); + return r.data.data; + }, + + deleteVehicleItem: async (id: number): Promise => { + await api.delete(`/api/checklisten/fahrzeug-items/${id}`); + }, + + // ── Checklists for a Vehicle ── + getChecklistenForVehicle: async (fahrzeugId: string): Promise => { + const r = await api.get(`/api/checklisten/fahrzeug/${fahrzeugId}/checklisten`); + return r.data.data; + }, + + // ── Executions ── + startExecution: async (fahrzeugId: string, vorlageId: number): Promise => { + const r = await api.post('/api/checklisten/ausfuehrungen', { fahrzeug_id: fahrzeugId, vorlage_id: vorlageId }); + return r.data.data; + }, + + submitExecution: async (id: string, data: SubmitAusfuehrungPayload): Promise => { + const r = await api.put(`/api/checklisten/ausfuehrungen/${id}`, data); + return r.data.data; + }, + + approveExecution: async (id: string): Promise => { + const r = await api.post(`/api/checklisten/ausfuehrungen/${id}/freigabe`); + return r.data.data; + }, + + getExecution: async (id: string): Promise => { + const r = await api.get(`/api/checklisten/ausfuehrungen/${id}`); + return r.data.data; + }, + + getExecutions: async (filter?: ChecklistAusfuehrungFilter): Promise => { + const params = new URLSearchParams(); + if (filter?.fahrzeug_id) params.set('fahrzeug_id', filter.fahrzeug_id); + if (filter?.vorlage_id != null) params.set('vorlage_id', String(filter.vorlage_id)); + if (filter?.status?.length) params.set('status', filter.status.join(',')); + const qs = params.toString(); + const r = await api.get(`/api/checklisten/ausfuehrungen${qs ? `?${qs}` : ''}`); + return r.data.data; + }, + + // ── Overdue / Due ── + getOverdue: async (): Promise => { + const r = await api.get('/api/checklisten/faellig'); + return r.data.data; + }, +}; diff --git a/frontend/src/services/fahrzeugTypen.ts b/frontend/src/services/fahrzeugTypen.ts new file mode 100644 index 0000000..76df5db --- /dev/null +++ b/frontend/src/services/fahrzeugTypen.ts @@ -0,0 +1,28 @@ +import { api } from './api'; +import type { FahrzeugTyp } from '../types/checklist.types'; + +export const fahrzeugTypenApi = { + getAll: async (): Promise => { + const r = await api.get('/api/fahrzeug-typen'); + return r.data.data; + }, + + getById: async (id: number): Promise => { + const r = await api.get(`/api/fahrzeug-typen/${id}`); + return r.data.data; + }, + + create: async (data: Partial): Promise => { + const r = await api.post('/api/fahrzeug-typen', data); + return r.data.data; + }, + + update: async (id: number, data: Partial): Promise => { + const r = await api.put(`/api/fahrzeug-typen/${id}`, data); + return r.data.data; + }, + + delete: async (id: number): Promise => { + await api.delete(`/api/fahrzeug-typen/${id}`); + }, +}; diff --git a/frontend/src/services/issues.ts b/frontend/src/services/issues.ts index d8319e6..bfae3d5 100644 --- a/frontend/src/services/issues.ts +++ b/frontend/src/services/issues.ts @@ -1,5 +1,5 @@ import { api } from './api'; -import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload, IssueTyp, IssueFilters, AssignableMember, IssueStatusDef, IssuePriorityDef, IssueWidgetSummary, IssueHistorie } from '../types/issue.types'; +import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload, IssueTyp, IssueFilters, AssignableMember, IssueStatusDef, IssuePriorityDef, IssueWidgetSummary, IssueHistorie, IssueDatei } from '../types/issue.types'; export const issuesApi = { getIssues: async (filters?: IssueFilters): Promise => { @@ -98,4 +98,20 @@ export const issuesApi = { deletePriority: async (id: number): Promise => { await api.delete(`/api/issues/priorities/${id}`); }, + // Files + uploadFile: async (issueId: number, file: File): Promise => { + const formData = new FormData(); + formData.append('file', file); + const r = await api.post(`/api/issues/${issueId}/files`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return r.data.data; + }, + getFiles: async (issueId: number): Promise => { + const r = await api.get(`/api/issues/${issueId}/files`); + return r.data.data; + }, + deleteFile: async (fileId: string): Promise => { + await api.delete(`/api/issues/files/${fileId}`); + }, }; diff --git a/frontend/src/types/checklist.types.ts b/frontend/src/types/checklist.types.ts new file mode 100644 index 0000000..5b91618 --- /dev/null +++ b/frontend/src/types/checklist.types.ts @@ -0,0 +1,162 @@ +export interface FahrzeugTyp { + id: number; + name: string; + beschreibung?: string; + icon?: string; + created_at: string; +} + +export interface ChecklistVorlageItem { + id: number; + vorlage_id: number; + bezeichnung: string; + beschreibung?: string; + pflicht: boolean; + sort_order: number; +} + +export interface ChecklistVorlage { + id: number; + name: string; + fahrzeug_typ_id?: number; + fahrzeug_typ?: FahrzeugTyp; + intervall?: 'weekly' | 'monthly' | 'yearly' | 'custom'; + intervall_tage?: number; + beschreibung?: string; + aktiv: boolean; + items?: ChecklistVorlageItem[]; + created_at: string; + updated_at: string; +} + +export interface FahrzeugChecklistItem { + id: number; + fahrzeug_id: string; + bezeichnung: string; + beschreibung?: string; + pflicht: boolean; + sort_order: number; + aktiv: boolean; +} + +export interface ChecklistAusfuehrungItem { + id: number; + ausfuehrung_id: string; + vorlage_item_id?: number; + fahrzeug_item_id?: number; + bezeichnung: string; + ergebnis?: 'ok' | 'nok' | 'na'; + kommentar?: string; + created_at: string; +} + +export interface ChecklistAusfuehrung { + id: string; + fahrzeug_id: string; + fahrzeug_name?: string; + vorlage_id?: number; + vorlage_name?: string; + status: 'offen' | 'abgeschlossen' | 'unvollstaendig' | 'freigegeben'; + ausgefuehrt_von?: string; + ausgefuehrt_von_name?: string; + ausgefuehrt_am?: string; + freigegeben_von?: string; + freigegeben_von_name?: string; + freigegeben_am?: string; + notizen?: string; + items?: ChecklistAusfuehrungItem[]; + created_at: string; +} + +export interface ChecklistFaelligkeit { + fahrzeug_id: string; + fahrzeug_name: string; + vorlage_id: number; + vorlage_name: string; + naechste_faellig_am: string; + letzte_ausfuehrung_id?: string; +} + +export type ChecklistAusfuehrungStatus = ChecklistAusfuehrung['status']; + +export const CHECKLIST_STATUS_LABELS: Record = { + offen: 'Offen', + abgeschlossen: 'Abgeschlossen', + unvollstaendig: 'Unvollst\u00e4ndig', + freigegeben: 'Freigegeben', +}; + +export const CHECKLIST_STATUS_COLORS: Record = { + offen: 'default', + abgeschlossen: 'info', + unvollstaendig: 'warning', + freigegeben: 'success', +}; + +export interface ChecklistVorlageFilter { + fahrzeug_typ_id?: number; + aktiv?: boolean; +} + +export interface ChecklistAusfuehrungFilter { + fahrzeug_id?: string; + vorlage_id?: number; + status?: ChecklistAusfuehrungStatus[]; +} + +export interface CreateVorlagePayload { + name: string; + fahrzeug_typ_id?: number; + intervall?: 'weekly' | 'monthly' | 'yearly' | 'custom'; + intervall_tage?: number; + beschreibung?: string; + aktiv?: boolean; +} + +export interface UpdateVorlagePayload { + name?: string; + fahrzeug_typ_id?: number | null; + intervall?: 'weekly' | 'monthly' | 'yearly' | 'custom' | null; + intervall_tage?: number | null; + beschreibung?: string | null; + aktiv?: boolean; +} + +export interface CreateVorlageItemPayload { + bezeichnung: string; + beschreibung?: string; + pflicht?: boolean; + sort_order?: number; +} + +export interface UpdateVorlageItemPayload { + bezeichnung?: string; + beschreibung?: string | null; + pflicht?: boolean; + sort_order?: number; +} + +export interface CreateFahrzeugItemPayload { + bezeichnung: string; + beschreibung?: string; + pflicht?: boolean; + sort_order?: number; +} + +export interface UpdateFahrzeugItemPayload { + bezeichnung?: string; + beschreibung?: string | null; + pflicht?: boolean; + sort_order?: number; + aktiv?: boolean; +} + +export interface SubmitAusfuehrungPayload { + items: { itemId: number; ergebnis: 'ok' | 'nok' | 'na'; kommentar?: string }[]; + notizen?: string; +} + +export interface ChecklistWidgetSummary { + overdue: ChecklistFaelligkeit[]; + dueSoon: ChecklistFaelligkeit[]; +} diff --git a/frontend/src/types/issue.types.ts b/frontend/src/types/issue.types.ts index c80854f..d99ae25 100644 --- a/frontend/src/types/issue.types.ts +++ b/frontend/src/types/issue.types.ts @@ -25,6 +25,7 @@ export interface Issue { erstellt_von_name?: string; zugewiesen_an: string | null; zugewiesen_an_name?: string | null; + faellig_am: string | null; created_at: string; updated_at: string; } @@ -53,6 +54,7 @@ export interface CreateIssuePayload { beschreibung?: string; typ_id?: number; prioritaet?: string; + faellig_am?: string | null; } export interface UpdateIssuePayload { @@ -63,6 +65,7 @@ export interface UpdateIssuePayload { status?: string; zugewiesen_an?: string | null; kommentar?: string; + faellig_am?: string | null; } export interface IssueFilters { @@ -108,3 +111,14 @@ export interface IssueStatusCount { } export type IssueWidgetSummary = IssueStatusCount[]; + +export interface IssueDatei { + id: string; + issue_id: number; + dateiname: string; + dateipfad: string; + dateityp: string | null; + dateigroesse: number | null; + hochgeladen_von: string | null; + hochgeladen_am: string; +}