feat: add issue kanban/attachments/deadlines, dashboard widget DnD, and checklisten system

This commit is contained in:
Matthias Hochmeister
2026-03-28 15:19:41 +01:00
parent a1cda5be51
commit 0c2ea829aa
42 changed files with 4804 additions and 201 deletions

View File

@@ -104,6 +104,8 @@ import permissionRoutes from './routes/permission.routes';
import ausruestungsanfrageRoutes from './routes/ausruestungsanfrage.routes'; import ausruestungsanfrageRoutes from './routes/ausruestungsanfrage.routes';
import issueRoutes from './routes/issue.routes'; import issueRoutes from './routes/issue.routes';
import buchungskategorieRoutes from './routes/buchungskategorie.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/auth', authRoutes);
app.use('/api/user', userRoutes); app.use('/api/user', userRoutes);
@@ -130,6 +132,8 @@ app.use('/api/permissions', permissionRoutes);
app.use('/api/ausruestungsanfragen', ausruestungsanfrageRoutes); app.use('/api/ausruestungsanfragen', ausruestungsanfrageRoutes);
app.use('/api/issues', issueRoutes); app.use('/api/issues', issueRoutes);
app.use('/api/buchungskategorien', buchungskategorieRoutes); app.use('/api/buchungskategorien', buchungskategorieRoutes);
app.use('/api/checklisten', checklistRoutes);
app.use('/api/fahrzeug-typen', fahrzeugTypRoutes);
// Static file serving for uploads (authenticated) // Static file serving for uploads (authenticated)
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads'); const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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();

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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();

View File

@@ -175,6 +175,7 @@ class IssueController {
titel: 'Titel geändert', titel: 'Titel geändert',
beschreibung: 'Beschreibung geändert', beschreibung: 'Beschreibung geändert',
typ_id: 'Typ geändert', typ_id: 'Typ geändert',
faellig_am: 'Fälligkeitsdatum geändert',
}; };
for (const [field, label] of Object.entries(fieldLabels)) { for (const [field, label] of Object.entries(fieldLabels)) {
if (field in updateData && updateData[field] !== existing[field]) { 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' }); res.status(500).json({ success: false, message: 'Priorität konnte nicht deaktiviert werden' });
} }
} }
// --- File management ---
async uploadFile(req: Request, res: Response): Promise<void> {
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<void> {
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<void> {
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(); export default new IssueController();

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 $$;

View File

@@ -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;

View File

@@ -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<typeof setInterval> | null = null;
let isRunning = false;
async function runChecklistReminderCheck(): Promise<void> {
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');
}

View File

@@ -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<typeof setInterval> | null = null;
let isRunning = false;
async function runIssueReminderCheck(): Promise<void> {
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');
}

View File

@@ -94,4 +94,42 @@ const wartungOptions: any = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export const uploadWartung: any = multer(wartungOptions); 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 };

View File

@@ -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;

View File

@@ -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;

View File

@@ -2,6 +2,7 @@ import { Router } from 'express';
import issueController from '../controllers/issue.controller'; import issueController from '../controllers/issue.controller';
import { authenticate } from '../middleware/auth.middleware'; import { authenticate } from '../middleware/auth.middleware';
import { requirePermission } from '../middleware/rbac.middleware'; import { requirePermission } from '../middleware/rbac.middleware';
import { uploadIssue } from '../middleware/upload';
const router = Router(); const router = Router();
@@ -60,6 +61,13 @@ router.get(
issueController.getMembers.bind(issueController) issueController.getMembers.bind(issueController)
); );
// --- File management (BEFORE /:id to avoid conflict) ---
router.delete(
'/files/:fileId',
authenticate,
issueController.deleteFile.bind(issueController)
);
// --- Issue CRUD --- // --- Issue CRUD ---
router.get( router.get(
'/', '/',
@@ -92,6 +100,19 @@ router.post(
issueController.addComment.bind(issueController) 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( router.get(
'/:id', '/:id',
authenticate, authenticate,

View File

@@ -5,6 +5,8 @@ import { testConnection, closePool, runMigrations } from './config/database';
import { startAuditCleanupJob, stopAuditCleanupJob } from './jobs/audit-cleanup.job'; import { startAuditCleanupJob, stopAuditCleanupJob } from './jobs/audit-cleanup.job';
import { startNotificationJob, stopNotificationJob } from './jobs/notification-generation.job'; import { startNotificationJob, stopNotificationJob } from './jobs/notification-generation.job';
import { startReminderJob, stopReminderJob } from './jobs/reminder.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'; import { permissionService } from './services/permission.service';
const startServer = async (): Promise<void> => { const startServer = async (): Promise<void> => {
@@ -32,6 +34,12 @@ const startServer = async (): Promise<void> => {
// Start the order reminder job // Start the order reminder job
startReminderJob(); startReminderJob();
// Start the issue reminder job
startIssueReminderJob();
// Start the checklist reminder job
startChecklistReminderJob();
// Start the server // Start the server
const server = app.listen(environment.port, () => { const server = app.listen(environment.port, () => {
logger.info('Server started successfully', { logger.info('Server started successfully', {
@@ -56,6 +64,8 @@ const startServer = async (): Promise<void> => {
stopAuditCleanupJob(); stopAuditCleanupJob();
stopNotificationJob(); stopNotificationJob();
stopReminderJob(); stopReminderJob();
stopIssueReminderJob();
stopChecklistReminderJob();
server.close(async () => { server.close(async () => {
logger.info('HTTP server closed'); logger.info('HTTP server closed');

View File

@@ -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,
};

View File

@@ -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();

View File

@@ -1,5 +1,6 @@
import pool from '../config/database'; import pool from '../config/database';
import logger from '../utils/logger'; import logger from '../utils/logger';
import fs from 'fs';
interface IssueFilters { interface IssueFilters {
typ_id?: number[]; typ_id?: number[];
@@ -92,13 +93,13 @@ async function getIssueById(id: number) {
} }
async function createIssue( 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 userId: string
) { ) {
try { try {
const result = await pool.query( const result = await pool.query(
`INSERT INTO issues (titel, beschreibung, typ_id, prioritaet, erstellt_von) `INSERT INTO issues (titel, beschreibung, typ_id, prioritaet, erstellt_von, faellig_am)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`, RETURNING *`,
[ [
data.titel, data.titel,
@@ -106,6 +107,7 @@ async function createIssue(
data.typ_id || 3, data.typ_id || 3,
data.prioritaet || 'mittel', data.prioritaet || 'mittel',
userId, userId,
data.faellig_am || null,
] ]
); );
return getIssueById(result.rows[0].id); return getIssueById(result.rows[0].id);
@@ -127,6 +129,7 @@ async function updateIssue(
prioritaet?: string; prioritaet?: string;
status?: string; status?: string;
zugewiesen_an?: string | null; zugewiesen_an?: string | null;
faellig_am?: string | null;
} }
) { ) {
try { try {
@@ -164,6 +167,11 @@ async function updateIssue(
values.push(data.zugewiesen_an ?? null); values.push(data.zugewiesen_an ?? null);
idx++; idx++;
} }
if ('faellig_am' in data) {
setClauses.push(`faellig_am = $${idx}`);
values.push(data.faellig_am ?? null);
idx++;
}
if (setClauses.length === 0) { if (setClauses.length === 0) {
return getIssueById(id); 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 { export default {
getIssues, getIssues,
getIssueById, getIssueById,
@@ -618,5 +712,9 @@ export default {
createIssuePriority, createIssuePriority,
updateIssuePriority, updateIssuePriority,
deleteIssuePriority, deleteIssuePriority,
addFile,
getFiles,
deleteFile,
getOverdueIssues,
UNASSIGN, UNASSIGN,
}; };

View File

@@ -137,6 +137,16 @@ class VehicleService {
? { titel: lehrgang.rows[0].titel, beginn: lehrgang.rows[0].beginn, ende: lehrgang.rows[0].ende } ? { titel: lehrgang.rows[0].titel, beginn: lehrgang.rows[0].beginn, ende: lehrgang.rows[0].ende }
: null; : 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; return vehicle;
} catch (error) { } catch (error) {
logger.error('VehicleService.getVehicleById failed', { error, id }); logger.error('VehicleService.getVehicleById failed', { error, id });

View File

@@ -8,6 +8,9 @@
"name": "feuerwehr-dashboard-frontend", "name": "feuerwehr-dashboard-frontend",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "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/react": "^11.11.1",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.18", "@mui/icons-material": "^5.14.18",
@@ -301,6 +304,60 @@
"node": ">=6.9.0" "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": { "node_modules/@emotion/babel-plugin": {
"version": "11.13.5", "version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", "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==", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT" "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": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",

View File

@@ -10,6 +10,9 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
}, },
"dependencies": { "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/react": "^11.11.1",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.18", "@mui/icons-material": "^5.14.18",

View File

@@ -36,6 +36,8 @@ import AusruestungsanfrageDetail from './pages/AusruestungsanfrageDetail';
import AusruestungsanfrageZuBestellung from './pages/AusruestungsanfrageZuBestellung'; import AusruestungsanfrageZuBestellung from './pages/AusruestungsanfrageZuBestellung';
import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail'; import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail';
import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu'; import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu';
import Checklisten from './pages/Checklisten';
import ChecklistAusfuehrung from './pages/ChecklistAusfuehrung';
import Issues from './pages/Issues'; import Issues from './pages/Issues';
import IssueDetail from './pages/IssueDetail'; import IssueDetail from './pages/IssueDetail';
import IssueNeu from './pages/IssueNeu'; import IssueNeu from './pages/IssueNeu';
@@ -342,6 +344,22 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/checklisten"
element={
<ProtectedRoute>
<Checklisten />
</ProtectedRoute>
}
/>
<Route
path="/checklisten/ausfuehrung/:id"
element={
<ProtectedRoute>
<ChecklistAusfuehrung />
</ProtectedRoute>
}
/>
<Route <Route
path="/issues/neu" path="/issues/neu"
element={ element={

View File

@@ -112,6 +112,12 @@ const PERMISSION_SUB_GROUPS: Record<string, Record<string, string[]>> = {
'Bearbeiten': ['create', 'change_status', 'edit', 'delete'], 'Bearbeiten': ['create', 'change_status', 'edit', 'delete'],
'Admin': ['edit_settings'], 'Admin': ['edit_settings'],
}, },
checklisten: {
'Ansehen': ['view'],
'Ausführen': ['execute', 'approve'],
'Verwaltung': ['manage_templates'],
'Widget': ['widget'],
},
admin: { admin: {
'Allgemein': ['view', 'write'], 'Allgemein': ['view', 'write'],
'Services': ['view_services', 'edit_services'], 'Services': ['view_services', 'edit_services'],

View File

@@ -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 (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Checklisten</Typography>
<Skeleton variant="rectangular" height={40} />
</CardContent>
</Card>
);
}
if (isError) {
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Checklisten</Typography>
<Typography variant="body2" color="text.secondary">
Checklisten konnten nicht geladen werden.
</Typography>
</CardContent>
</Card>
);
}
return (
<Card sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }} onClick={() => navigate('/checklisten')}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6">Checklisten</Typography>
{overdueItems.length > 0 && (
<Chip
icon={<Warning />}
label={overdueItems.length}
color="error"
size="small"
/>
)}
</Box>
<AssignmentTurnedIn fontSize="small" color="action" />
</Box>
{overdueItems.length === 0 ? (
<Typography variant="body2" color="text.secondary">Alle Checklisten aktuell</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{overdueItems.slice(0, 5).map((item) => {
const days = Math.ceil((Date.now() - new Date(item.naechste_faellig_am).getTime()) / 86400000);
return (
<Box key={`${item.fahrzeug_id}-${item.vorlage_id}`} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" noWrap sx={{ maxWidth: '60%' }}>
{item.fahrzeug_name}
</Typography>
<Chip
label={`${item.vorlage_name} \u2013 ${days > 0 ? `${days}d` : 'heute'}`}
color={days > 7 ? 'error' : days > 0 ? 'warning' : 'info'}
size="small"
variant="outlined"
/>
</Box>
);
})}
{overdueItems.length > 5 && (
<Typography variant="caption" color="text.secondary">
+ {overdueItems.length - 5} weitere
</Typography>
)}
</Box>
)}
</CardContent>
</Card>
);
}
export default ChecklistWidget;

View File

@@ -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 (
<Box ref={setNodeRef} style={style}>
{editMode && (
<IconButton
size="small"
{...attributes}
{...listeners}
sx={{
position: 'absolute',
top: 4,
left: 4,
zIndex: 10,
cursor: 'grab',
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<DragIndicator fontSize="small" />
</IconButton>
)}
{children}
</Box>
);
}

View File

@@ -22,3 +22,5 @@ export { default as BestellungenWidget } from './BestellungenWidget';
export { default as AusruestungsanfrageWidget } from './AusruestungsanfrageWidget'; export { default as AusruestungsanfrageWidget } from './AusruestungsanfrageWidget';
export { default as IssueQuickAddWidget } from './IssueQuickAddWidget'; export { default as IssueQuickAddWidget } from './IssueQuickAddWidget';
export { default as IssueOverviewWidget } from './IssueOverviewWidget'; export { default as IssueOverviewWidget } from './IssueOverviewWidget';
export { default as ChecklistWidget } from './ChecklistWidget';
export { default as SortableWidget } from './SortableWidget';

View File

@@ -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<FahrzeugChecklistTabProps> = ({ 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<CreateFahrzeugItemPayload>({ 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 (
<Box>
{/* Section 1: Vehicle-specific items */}
{canManage && (
<Box sx={{ mb: 4 }}>
<Typography variant="h6" sx={{ mb: 1.5 }}>Fahrzeugspezifische Checklisten-Items</Typography>
{itemsLoading ? (
<CircularProgress size={24} />
) : (
<>
<TableContainer component={Paper} variant="outlined" sx={{ mb: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell align="center">Pflicht</TableCell>
<TableCell align="center">Aktiv</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{vehicleItems.length === 0 ? (
<TableRow><TableCell colSpan={4} align="center">Keine fahrzeugspezifischen Items</TableCell></TableRow>
) : (
vehicleItems.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.bezeichnung}</TableCell>
<TableCell align="center">{item.pflicht ? <Chip label="Pflicht" size="small" color="warning" /> : '\u2013'}</TableCell>
<TableCell align="center">
<Switch
size="small"
checked={item.aktiv}
onChange={() => toggleItemMutation.mutate({ id: item.id, aktiv: !item.aktiv })}
/>
</TableCell>
<TableCell align="right">
<IconButton size="small" color="error" onClick={() => deleteItemMutation.mutate(item.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField size="small" placeholder="Neues Item..." value={newItem.bezeichnung} onChange={(e) => setNewItem((n) => ({ ...n, bezeichnung: e.target.value }))} sx={{ flexGrow: 1 }} />
<FormControlLabel control={<Switch size="small" checked={newItem.pflicht} onChange={(e) => setNewItem((n) => ({ ...n, pflicht: e.target.checked }))} />} label="Pflicht" />
<Button size="small" variant="outlined" startIcon={<Add />} disabled={!newItem.bezeichnung.trim() || addItemMutation.isPending} onClick={() => addItemMutation.mutate(newItem)}>
Hinzuf\u00fcgen
</Button>
</Box>
</>
)}
</Box>
)}
{/* Section 2: Applicable templates */}
<Box sx={{ mb: 4 }}>
<Typography variant="h6" sx={{ mb: 1.5 }}>Anwendbare Vorlagen</Typography>
{templatesLoading ? (
<CircularProgress size={24} />
) : templates.length === 0 ? (
<Typography color="text.secondary">Keine Vorlagen f\u00fcr dieses Fahrzeug.</Typography>
) : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Vorlage</TableCell>
<TableCell>Intervall</TableCell>
<TableCell>N\u00e4chste F\u00e4lligkeit</TableCell>
<TableCell align="right">Aktion</TableCell>
</TableRow>
</TableHead>
<TableBody>
{templates.map((t) => {
const due = vehicleOverdue.find((f) => f.vorlage_id === t.id);
return (
<TableRow key={t.id}>
<TableCell>{t.name}</TableCell>
<TableCell>
{t.intervall === 'weekly' ? 'W\u00f6chentlich' : t.intervall === 'monthly' ? 'Monatlich' : t.intervall === 'yearly' ? 'J\u00e4hrlich' : t.intervall === 'custom' ? `${t.intervall_tage ?? '?'} Tage` : '\u2013'}
</TableCell>
<TableCell>
{due ? (
<Chip icon={<Warning />} label={`F\u00e4llig: ${formatDate(due.naechste_faellig_am)}`} color="error" size="small" />
) : (
<Typography variant="body2" color="text.secondary">Aktuell</Typography>
)}
</TableCell>
<TableCell align="right">
{canExecute && (
<Button
size="small"
variant="outlined"
startIcon={<PlayArrow />}
onClick={() => navigate(`/checklisten/ausfuehrung/new?fahrzeug=${fahrzeugId}&vorlage=${t.id}`)}
>
Ausf\u00fchren
</Button>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
{/* Section 3: Recent executions */}
<Box>
<Typography variant="h6" sx={{ mb: 1.5 }}>Letzte Ausf\u00fchrungen</Typography>
{executionsLoading ? (
<CircularProgress size={24} />
) : executions.length === 0 ? (
<Typography color="text.secondary">Noch keine Ausf\u00fchrungen f\u00fcr dieses Fahrzeug.</Typography>
) : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Datum</TableCell>
<TableCell>Vorlage</TableCell>
<TableCell>Status</TableCell>
<TableCell>Ausgef\u00fchrt von</TableCell>
<TableCell>Freigegeben von</TableCell>
</TableRow>
</TableHead>
<TableBody>
{executions.slice(0, 20).map((e) => (
<TableRow key={e.id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/checklisten/ausfuehrung/${e.id}`)}>
<TableCell>{formatDate(e.ausgefuehrt_am ?? e.created_at)}</TableCell>
<TableCell>{e.vorlage_name ?? '\u2013'}</TableCell>
<TableCell>
<Chip label={CHECKLIST_STATUS_LABELS[e.status]} color={CHECKLIST_STATUS_COLORS[e.status]} size="small" />
</TableCell>
<TableCell>{e.ausgefuehrt_von_name ?? '\u2013'}</TableCell>
<TableCell>{e.freigegeben_von_name ?? '\u2013'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
</Box>
);
};
export default FahrzeugChecklistTab;

View File

@@ -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<string, string> = {
hoch: '#d32f2f', mittel: '#ed6c02', niedrig: '#9e9e9e',
};
const MUI_THEME_COLORS: Record<string, string> = {
default: '#9e9e9e', primary: '#1976d2', secondary: '#9c27b0',
error: '#d32f2f', info: '#0288d1', success: '#2e7d32', warning: '#ed6c02',
};
const ICON_MAP: Record<string, JSX.Element> = {
BugReport: <BugReport fontSize="small" />,
FiberNew: <FiberNew fontSize="small" />,
HelpOutline: <HelpOutline fontSize="small" />,
};
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 || ''] || <HelpOutline fontSize="small" />;
const overdue = isOverdue(issue);
return (
<Paper
variant="outlined"
onClick={() => 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 */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
<Box sx={{ display: 'inline-flex', mt: 0.25, color: `${issue.typ_farbe || 'action'}.main` }}>
{icon}
</Box>
<Typography variant="body2" sx={{ fontWeight: 500, flex: 1, lineHeight: 1.3 }}>
{issue.titel}
</Typography>
</Box>
{/* Bottom row: assignee + overdue */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{issue.zugewiesen_an_name && (
<Avatar sx={{ width: 20, height: 20, fontSize: 10 }}>
{issue.zugewiesen_an_name.charAt(0).toUpperCase()}
</Avatar>
)}
<Typography variant="caption" color="text.secondary">
{issue.zugewiesen_an_name || ''}
</Typography>
</Box>
{overdue && (
<Chip
icon={<AccessTimeIcon sx={{ fontSize: 14 }} />}
label="Überfällig"
size="small"
color="error"
variant="outlined"
sx={{ height: 20, '& .MuiChip-label': { px: 0.5, fontSize: 11 } }}
/>
)}
</Box>
</Paper>
);
}
// ── 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 (
<Box ref={setNodeRef} style={style} {...attributes} {...listeners}>
<KanbanCard issue={issue} priorities={priorities} onClick={onClick} isDragging={isDragging} />
</Box>
);
}
// ── 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 (
<Box
sx={{
minWidth: 260,
maxWidth: 320,
flex: '1 1 260px',
display: 'flex',
flexDirection: 'column',
bgcolor: 'background.default',
borderRadius: 1,
overflow: 'hidden',
}}
>
{/* Column header */}
<Box sx={{ px: 1.5, py: 1, display: 'flex', alignItems: 'center', gap: 1, borderBottom: 1, borderColor: 'divider' }}>
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: chipColor, flexShrink: 0 }} />
<Typography variant="subtitle2" sx={{ flex: 1 }}>{statusDef.bezeichnung}</Typography>
<Typography variant="caption" color="text.secondary">{issues.length}</Typography>
</Box>
{/* Card list */}
<SortableContext items={issues.map(i => i.id)} strategy={verticalListSortingStrategy}>
<Box
sx={{
p: 1,
display: 'flex',
flexDirection: 'column',
gap: 1,
minHeight: 100,
overflowY: 'auto',
flex: 1,
}}
>
{issues.length === 0 && (
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center', py: 2 }}>
Keine Issues
</Typography>
)}
{issues.map((issue) => (
<SortableCard key={issue.id} issue={issue} priorities={priorities} onClick={onClick} />
))}
</Box>
</SortableContext>
</Box>
);
}
// ── 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<number | null>(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<string, Issue[]> = {};
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 (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<Box
sx={{
display: 'flex',
gap: 1.5,
overflowX: 'auto',
pb: 1,
minHeight: 400,
}}
>
{activeStatuses.map((s) => (
<KanbanColumn
key={s.schluessel}
statusDef={s}
issues={issuesByStatus[s.schluessel] || []}
priorities={priorities}
onClick={onNavigate}
/>
))}
</Box>
<DragOverlay>
{activeIssue ? (
<KanbanCard issue={activeIssue} priorities={priorities} onClick={() => {}} />
) : null}
</DragOverlay>
</DndContext>
);
}

View File

@@ -29,6 +29,7 @@ import {
BugReport, BugReport,
BookOnline, BookOnline,
Forum, Forum,
AssignmentTurnedIn,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@@ -136,6 +137,12 @@ const baseNavigationItems: NavigationItem[] = [
// subItems computed dynamically in navigationItems useMemo // subItems computed dynamically in navigationItems useMemo
permission: 'ausruestungsanfrage:view', permission: 'ausruestungsanfrage:view',
}, },
{
text: 'Checklisten',
icon: <AssignmentTurnedIn />,
path: '/checklisten',
permission: 'checklisten:view',
},
{ {
text: 'Issues', text: 'Issues',
icon: <BugReport />, icon: <BugReport />,

View File

@@ -17,6 +17,7 @@ export const WIDGETS = [
{ key: 'ausruestungsanfragen', label: 'Interne Bestellungen', defaultVisible: true }, { key: 'ausruestungsanfragen', label: 'Interne Bestellungen', defaultVisible: true },
{ key: 'issueQuickAdd', label: 'Issue melden', defaultVisible: true }, { key: 'issueQuickAdd', label: 'Issue melden', defaultVisible: true },
{ key: 'issueOverview', label: 'Issue Übersicht', defaultVisible: true }, { key: 'issueOverview', label: 'Issue Übersicht', defaultVisible: true },
{ key: 'checklistOverdue', label: 'Checklisten', defaultVisible: true },
] as const; ] as const;
export type WidgetKey = typeof WIDGETS[number]['key']; export type WidgetKey = typeof WIDGETS[number]['key'];

View File

@@ -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<string, JSX.Element> = {
ok: <CheckCircle fontSize="small" color="success" />,
nok: <Cancel fontSize="small" color="error" />,
na: <RemoveCircle fontSize="small" color="disabled" />,
};
// ══════════════════════════════════════════════════════════════════════════════
// 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<Record<number, { ergebnis: 'ok' | 'nok' | 'na'; kommentar: string }>>({});
const [notizen, setNotizen] = useState('');
useEffect(() => {
if (!execution?.items) return;
const results: Record<number, { ergebnis: 'ok' | 'nok' | 'na'; kommentar: string }> = {};
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 (
<DashboardLayout>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>
</DashboardLayout>
);
}
if (isLoading) {
return (
<DashboardLayout>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>
</DashboardLayout>
);
}
if (isError || !execution) {
return (
<DashboardLayout>
<Alert severity="error">Checkliste konnte nicht geladen werden.</Alert>
<Button startIcon={<ArrowBack />} onClick={() => navigate('/checklisten')} sx={{ mt: 2 }}>Zur\u00fcck</Button>
</DashboardLayout>
);
}
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 (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1 }}>{title}</Typography>
{groupItems.map((item) => {
const result = itemResults[item.id];
return (
<Paper key={item.id} variant="outlined" sx={{ p: 2, mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="body1" sx={{ fontWeight: 500, flexGrow: 1 }}>
{item.bezeichnung}
{/* Indicate pflicht with asterisk - we don't have pflicht on ausfuehrung items directly but could check */}
</Typography>
{isReadOnly && result?.ergebnis && ERGEBNIS_ICONS[result.ergebnis]}
</Box>
{isReadOnly ? (
<Box>
<Chip
label={result?.ergebnis === 'ok' ? 'OK' : result?.ergebnis === 'nok' ? 'Nicht OK' : 'N/A'}
color={result?.ergebnis === 'ok' ? 'success' : result?.ergebnis === 'nok' ? 'error' : 'default'}
size="small"
/>
{result?.kommentar && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{result.kommentar}</Typography>
)}
</Box>
) : (
<Box>
<RadioGroup
row
value={result?.ergebnis ?? ''}
onChange={(e) => setItemResult(item.id, e.target.value as 'ok' | 'nok' | 'na')}
>
<FormControlLabel value="ok" control={<Radio size="small" />} label="OK" />
<FormControlLabel value="nok" control={<Radio size="small" />} label="Nicht OK" />
<FormControlLabel value="na" control={<Radio size="small" />} label="N/A" />
</RadioGroup>
<TextField
size="small"
placeholder="Kommentar (optional)"
fullWidth
value={result?.kommentar ?? ''}
onChange={(e) => setItemComment(item.id, e.target.value)}
sx={{ mt: 0.5 }}
/>
</Box>
)}
</Paper>
);
})}
</Box>
);
};
return (
<DashboardLayout>
<Button startIcon={<ArrowBack />} onClick={() => navigate('/checklisten')} sx={{ mb: 2 }} size="small">
Checklisten
</Button>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Box>
<Typography variant="h4">
{execution.vorlage_name ?? 'Checkliste'}
</Typography>
<Typography variant="subtitle1" color="text.secondary">
{execution.fahrzeug_name ?? '\u2013'} &middot; {formatDate(execution.ausgefuehrt_am ?? execution.created_at)}
</Typography>
</Box>
<Chip
label={CHECKLIST_STATUS_LABELS[execution.status]}
color={CHECKLIST_STATUS_COLORS[execution.status]}
/>
</Box>
{execution.status === 'unvollstaendig' && (
<Alert severity="warning" sx={{ mb: 2 }}>
Diese Checkliste wurde als unvollst\u00e4ndig abgeschlossen. Einige Pflicht-Items wurden nicht mit &quot;OK&quot; bewertet.
</Alert>
)}
<Card sx={{ mb: 3 }}>
<CardContent>
{renderItemGroup(vorlageItems, 'Vorlage-Items')}
{vorlageItems.length > 0 && vehicleItems.length > 0 && <Divider sx={{ my: 2 }} />}
{renderItemGroup(vehicleItems, 'Fahrzeugspezifische Items')}
{items.length === 0 && (
<Typography color="text.secondary">Keine Items in dieser Checkliste.</Typography>
)}
</CardContent>
</Card>
{/* Notes */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1 }}>Notizen</Typography>
{isReadOnly ? (
<Typography variant="body2" color="text.secondary">{execution.notizen || 'Keine Notizen'}</Typography>
) : (
<TextField
fullWidth
multiline
rows={3}
placeholder="Zus\u00e4tzliche Notizen..."
value={notizen}
onChange={(e) => setNotizen(e.target.value)}
/>
)}
</CardContent>
</Card>
{/* Actions */}
<Box sx={{ display: 'flex', gap: 2 }}>
{execution.status === 'offen' && (
<Button
variant="contained"
onClick={() => submitMutation.mutate()}
disabled={submitMutation.isPending}
startIcon={submitMutation.isPending ? <CircularProgress size={16} /> : undefined}
>
Abschlie\u00dfen
</Button>
)}
{canApprove && execution.status === 'abgeschlossen' && (
<Button
variant="contained"
color="success"
onClick={() => approveMutation.mutate()}
disabled={approveMutation.isPending}
startIcon={approveMutation.isPending ? <CircularProgress size={16} /> : undefined}
>
Freigeben
</Button>
)}
</Box>
{/* Metadata */}
{(execution.ausgefuehrt_von_name || execution.freigegeben_von_name) && (
<Paper variant="outlined" sx={{ p: 2, mt: 3 }}>
{execution.ausgefuehrt_von_name && (
<Typography variant="body2" color="text.secondary">
Ausgef\u00fchrt von: {execution.ausgefuehrt_von_name} am {formatDate(execution.ausgefuehrt_am)}
</Typography>
)}
{execution.freigegeben_von_name && (
<Typography variant="body2" color="text.secondary">
Freigegeben von: {execution.freigegeben_von_name} am {formatDate(execution.freigegeben_am)}
</Typography>
)}
</Paper>
)}
</DashboardLayout>
);
}

View File

@@ -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<string, string> = {
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 <Box sx={{ pt: 3 }}>{children}</Box>;
}
// ══════════════════════════════════════════════════════════════════════════════
// 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<Record<string, ChecklistFaelligkeit[]>>((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 (
<DashboardLayout>
<Typography variant="h4" sx={{ mb: 3 }}>Checklisten</Typography>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs
value={tab}
onChange={(_e, v) => { setTab(v); navigate(`/checklisten?tab=${v}`, { replace: true }); }}
variant="scrollable"
scrollButtons="auto"
>
<Tab label="\u00dcbersicht" />
{canManageTemplates && <Tab label="Vorlagen" />}
{canManageTemplates && <Tab label="Fahrzeugtypen" />}
<Tab label="Historie" />
</Tabs>
</Box>
{/* Tab 0: \u00dcbersicht */}
<TabPanel value={tab} index={0}>
{vehiclesLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : vehicles.length === 0 ? (
<Typography color="text.secondary">Keine Fahrzeuge vorhanden.</Typography>
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 2 }}>
{vehicles.map((v) => {
const vOverdue = overdueByVehicle[v.id] || [];
return (
<Card key={v.id} variant="outlined">
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="h6">{v.bezeichnung ?? v.kurzname}</Typography>
{vOverdue.length > 0 && (
<Chip
icon={<Warning />}
label={`${vOverdue.length} f\u00e4llig`}
color="error"
size="small"
/>
)}
</Box>
{vOverdue.length > 0 ? (
vOverdue.map((f) => {
const days = Math.ceil((Date.now() - new Date(f.naechste_faellig_am).getTime()) / 86400000);
return (
<Box key={f.vorlage_id} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', py: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Warning fontSize="small" color="error" />
<Typography variant="body2">{f.vorlage_name}</Typography>
<Typography variant="caption" color="error.main">
({days > 0 ? `${days}d \u00fcberf\u00e4llig` : 'heute f\u00e4llig'})
</Typography>
</Box>
{canExecute && (
<Tooltip title="Checkliste starten">
<IconButton
size="small"
color="primary"
onClick={() => navigate(`/checklisten/ausfuehrung/new?fahrzeug=${v.id}&vorlage=${f.vorlage_id}`)}
>
<PlayArrow fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
);
})
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CheckCircle fontSize="small" color="success" />
<Typography variant="body2" color="text.secondary">Alle Checklisten aktuell</Typography>
</Box>
)}
</CardContent>
</Card>
);
})}
</Box>
)}
</TabPanel>
{/* Tab 1: Vorlagen (templates) */}
{canManageTemplates && (
<TabPanel value={tab} index={vorlagenTabIdx}>
<VorlagenTab
vorlagen={vorlagen}
loading={vorlagenLoading}
fahrzeugTypen={fahrzeugTypen}
queryClient={queryClient}
showSuccess={showSuccess}
showError={showError}
/>
</TabPanel>
)}
{/* Tab 2: Fahrzeugtypen */}
{canManageTemplates && (
<TabPanel value={tab} index={typenTabIdx}>
<FahrzeugTypenTab
fahrzeugTypen={fahrzeugTypen}
queryClient={queryClient}
showSuccess={showSuccess}
showError={showError}
/>
</TabPanel>
)}
{/* Tab 3: Historie */}
<TabPanel value={tab} index={historieTabIdx}>
<HistorieTab
executions={executions}
loading={executionsLoading}
navigate={navigate}
/>
</TabPanel>
</DashboardLayout>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// Vorlagen Tab
// ══════════════════════════════════════════════════════════════════════════════
interface VorlagenTabProps {
vorlagen: ChecklistVorlage[];
loading: boolean;
fahrzeugTypen: FahrzeugTyp[];
queryClient: ReturnType<typeof useQueryClient>;
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<ChecklistVorlage | null>(null);
const [expandedVorlageId, setExpandedVorlageId] = useState<number | null>(null);
const emptyForm: CreateVorlagePayload = { name: '', fahrzeug_typ_id: undefined, intervall: undefined, intervall_tage: undefined, beschreibung: '', aktiv: true };
const [form, setForm] = useState<CreateVorlagePayload>(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 <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>Neue Vorlage</Button>
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Fahrzeugtyp</TableCell>
<TableCell>Intervall</TableCell>
<TableCell>Aktiv</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{vorlagen.length === 0 ? (
<TableRow><TableCell colSpan={5} align="center">Keine Vorlagen vorhanden</TableCell></TableRow>
) : (
vorlagen.map((v) => (
<React.Fragment key={v.id}>
<TableRow hover sx={{ cursor: 'pointer' }} onClick={() => setExpandedVorlageId(expandedVorlageId === v.id ? null : v.id)}>
<TableCell>{v.name}</TableCell>
<TableCell>{v.fahrzeug_typ?.name ?? '\u2013'}</TableCell>
<TableCell>
{v.intervall ? INTERVALL_LABELS[v.intervall] || v.intervall : '\u2013'}
{v.intervall === 'custom' && v.intervall_tage ? ` (${v.intervall_tage} Tage)` : ''}
</TableCell>
<TableCell>
<Chip label={v.aktiv ? 'Aktiv' : 'Inaktiv'} color={v.aktiv ? 'success' : 'default'} size="small" />
</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={(e) => { e.stopPropagation(); openEdit(v); }}><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(v.id); }}><DeleteIcon fontSize="small" /></IconButton>
</TableCell>
</TableRow>
{expandedVorlageId === v.id && (
<TableRow>
<TableCell colSpan={5} sx={{ p: 2, bgcolor: 'action.hover' }}>
<VorlageItemsSection vorlageId={v.id} queryClient={queryClient} showSuccess={showSuccess} showError={showError} />
</TableCell>
</TableRow>
)}
</React.Fragment>
))
)}
</TableBody>
</Table>
</TableContainer>
{/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{editingVorlage ? 'Vorlage bearbeiten' : 'Neue Vorlage'}</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField label="Name *" fullWidth value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
<FormControl fullWidth>
<InputLabel>Fahrzeugtyp</InputLabel>
<Select label="Fahrzeugtyp" value={form.fahrzeug_typ_id ?? ''} onChange={(e) => setForm((f) => ({ ...f, fahrzeug_typ_id: e.target.value ? Number(e.target.value) : undefined }))}>
<MenuItem value="">Alle (global)</MenuItem>
{fahrzeugTypen.map((t) => <MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>)}
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Intervall</InputLabel>
<Select label="Intervall" value={form.intervall ?? ''} onChange={(e) => setForm((f) => ({ ...f, intervall: (e.target.value || undefined) as CreateVorlagePayload['intervall'] }))}>
<MenuItem value="">Kein Intervall</MenuItem>
<MenuItem value="weekly">W\u00f6chentlich</MenuItem>
<MenuItem value="monthly">Monatlich</MenuItem>
<MenuItem value="yearly">J\u00e4hrlich</MenuItem>
<MenuItem value="custom">Benutzerdefiniert</MenuItem>
</Select>
</FormControl>
{form.intervall === 'custom' && (
<TextField label="Intervall (Tage)" type="number" fullWidth value={form.intervall_tage ?? ''} onChange={(e) => setForm((f) => ({ ...f, intervall_tage: e.target.value ? Number(e.target.value) : undefined }))} inputProps={{ min: 1 }} />
)}
<TextField label="Beschreibung" fullWidth multiline rows={2} value={form.beschreibung ?? ''} onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))} />
<FormControlLabel control={<Switch checked={form.aktiv !== false} onChange={(e) => setForm((f) => ({ ...f, aktiv: e.target.checked }))} />} label="Aktiv" />
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
<Button variant="contained" onClick={handleSubmit} disabled={isSaving || !form.name.trim()}>
{isSaving ? <CircularProgress size={20} /> : 'Speichern'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
// ── Vorlage Items Sub-section ──
function VorlageItemsSection({ vorlageId, queryClient, showSuccess, showError }: { vorlageId: number; queryClient: ReturnType<typeof useQueryClient>; 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<CreateVorlageItemPayload>({ 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 <CircularProgress size={20} />;
return (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Checklisten-Items</Typography>
{items.map((item) => (
<Box key={item.id} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ flexGrow: 1 }}>
{item.bezeichnung} {item.pflicht && <Chip label="Pflicht" size="small" color="warning" sx={{ ml: 0.5 }} />}
</Typography>
<IconButton size="small" color="error" onClick={() => deleteMutation.mutate(item.id)}><DeleteIcon fontSize="small" /></IconButton>
</Box>
))}
<Box sx={{ display: 'flex', gap: 1, mt: 1, alignItems: 'center' }}>
<TextField size="small" placeholder="Neues Item..." value={newItem.bezeichnung} onChange={(e) => setNewItem((n) => ({ ...n, bezeichnung: e.target.value }))} sx={{ flexGrow: 1 }} />
<FormControlLabel control={<Switch size="small" checked={newItem.pflicht} onChange={(e) => setNewItem((n) => ({ ...n, pflicht: e.target.checked }))} />} label="Pflicht" />
<Button size="small" variant="outlined" disabled={!newItem.bezeichnung.trim() || addMutation.isPending} onClick={() => addMutation.mutate(newItem)}>
Hinzuf\u00fcgen
</Button>
</Box>
</Box>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// Fahrzeugtypen Tab
// ══════════════════════════════════════════════════════════════════════════════
interface FahrzeugTypenTabProps {
fahrzeugTypen: FahrzeugTyp[];
queryClient: ReturnType<typeof useQueryClient>;
showSuccess: (msg: string) => void;
showError: (msg: string) => void;
}
function FahrzeugTypenTab({ fahrzeugTypen, queryClient, showSuccess, showError }: FahrzeugTypenTabProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<FahrzeugTyp | null>(null);
const [form, setForm] = useState({ name: '', beschreibung: '', icon: '' });
const createMutation = useMutation({
mutationFn: (data: Partial<FahrzeugTyp>) => 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<FahrzeugTyp> }) => 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 (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>Neuer Fahrzeugtyp</Button>
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell>Icon</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{fahrzeugTypen.length === 0 ? (
<TableRow><TableCell colSpan={4} align="center">Keine Fahrzeugtypen vorhanden</TableCell></TableRow>
) : (
fahrzeugTypen.map((t) => (
<TableRow key={t.id} hover>
<TableCell>{t.name}</TableCell>
<TableCell>{t.beschreibung ?? '\u2013'}</TableCell>
<TableCell>{t.icon ?? '\u2013'}</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => openEdit(t)}><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deleteMutation.mutate(t.id)}><DeleteIcon fontSize="small" /></IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{editing ? 'Fahrzeugtyp bearbeiten' : 'Neuer Fahrzeugtyp'}</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField label="Name *" fullWidth value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
<TextField label="Beschreibung" fullWidth value={form.beschreibung} onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))} />
<TextField label="Icon" fullWidth value={form.icon} onChange={(e) => setForm((f) => ({ ...f, icon: e.target.value }))} placeholder="z.B. fire_truck" />
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
<Button variant="contained" onClick={handleSubmit} disabled={isSaving || !form.name.trim()}>
{isSaving ? <CircularProgress size={20} /> : 'Speichern'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// Historie Tab
// ══════════════════════════════════════════════════════════════════════════════
interface HistorieTabProps {
executions: ChecklistAusfuehrung[];
loading: boolean;
navigate: ReturnType<typeof useNavigate>;
}
function HistorieTab({ executions, loading, navigate }: HistorieTabProps) {
const [statusFilter, setStatusFilter] = useState<string>('');
const [vehicleFilter, setVehicleFilter] = useState<string>('');
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 <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
return (
<Box>
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Status</InputLabel>
<Select label="Status" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<MenuItem value="">Alle</MenuItem>
{Object.entries(CHECKLIST_STATUS_LABELS).map(([key, label]) => (
<MenuItem key={key} value={key}>{label}</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Fahrzeug</InputLabel>
<Select label="Fahrzeug" value={vehicleFilter} onChange={(e) => setVehicleFilter(e.target.value)}>
<MenuItem value="">Alle</MenuItem>
{uniqueVehicles.map((v) => <MenuItem key={v} value={v}>{v}</MenuItem>)}
</Select>
</FormControl>
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Fahrzeug</TableCell>
<TableCell>Vorlage</TableCell>
<TableCell>Datum</TableCell>
<TableCell>Status</TableCell>
<TableCell>Ausgef\u00fchrt von</TableCell>
<TableCell>Freigegeben von</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filtered.length === 0 ? (
<TableRow><TableCell colSpan={6} align="center">Keine Eintr\u00e4ge</TableCell></TableRow>
) : (
filtered.map((e) => (
<TableRow key={e.id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/checklisten/ausfuehrung/${e.id}`)}>
<TableCell>{e.fahrzeug_name ?? '\u2013'}</TableCell>
<TableCell>{e.vorlage_name ?? '\u2013'}</TableCell>
<TableCell>{formatDate(e.ausgefuehrt_am ?? e.created_at)}</TableCell>
<TableCell>
<Chip label={CHECKLIST_STATUS_LABELS[e.status]} color={CHECKLIST_STATUS_COLORS[e.status]} size="small" />
</TableCell>
<TableCell>{e.ausgefuehrt_von_name ?? '\u2013'}</TableCell>
<TableCell>{e.freigegeben_von_name ?? '\u2013'}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Box>
);
}

View File

@@ -1,10 +1,26 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import { import {
Container, Container,
Box, Box,
Fade, Fade,
IconButton,
Tooltip,
} from '@mui/material'; } 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 { useAuth } from '../contexts/AuthContext';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
@@ -28,18 +44,52 @@ import EventQuickAddWidget from '../components/dashboard/EventQuickAddWidget';
import LinksWidget from '../components/dashboard/LinksWidget'; import LinksWidget from '../components/dashboard/LinksWidget';
import BannerWidget from '../components/dashboard/BannerWidget'; import BannerWidget from '../components/dashboard/BannerWidget';
import WidgetGroup from '../components/dashboard/WidgetGroup'; import WidgetGroup from '../components/dashboard/WidgetGroup';
import SortableWidget from '../components/dashboard/SortableWidget';
import BestellungenWidget from '../components/dashboard/BestellungenWidget'; import BestellungenWidget from '../components/dashboard/BestellungenWidget';
import AusruestungsanfrageWidget from '../components/dashboard/AusruestungsanfrageWidget'; import AusruestungsanfrageWidget from '../components/dashboard/AusruestungsanfrageWidget';
import IssueQuickAddWidget from '../components/dashboard/IssueQuickAddWidget'; import IssueQuickAddWidget from '../components/dashboard/IssueQuickAddWidget';
import IssueOverviewWidget from '../components/dashboard/IssueOverviewWidget'; import IssueOverviewWidget from '../components/dashboard/IssueOverviewWidget';
import ChecklistWidget from '../components/dashboard/ChecklistWidget';
import { preferencesApi } from '../services/settings'; import { preferencesApi } from '../services/settings';
import { configApi } from '../services/config'; import { configApi } from '../services/config';
import { WidgetKey } from '../constants/widgets'; 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<GroupName, string[]> = {
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() { function Dashboard() {
const { user } = useAuth(); const { user } = useAuth();
const { hasPermission } = usePermissionContext(); const { hasPermission } = usePermissionContext();
const queryClient = useQueryClient();
const [dataLoading, setDataLoading] = useState(true); const [dataLoading, setDataLoading] = useState(true);
const [editMode, setEditMode] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const { data: preferences } = useQuery({ const { data: preferences } = useQuery({
queryKey: ['user-preferences'], queryKey: ['user-preferences'],
@@ -60,203 +110,214 @@ function Dashboard() {
return preferences?.widgets?.[key] !== false; return preferences?.widgets?.[key] !== false;
}; };
// Build widget definitions for each group
const widgetDefs: Record<GroupName, WidgetDef[]> = useMemo(() => ({
status: [
{ key: 'vehicles', widgetKey: 'vehicles', permission: 'fahrzeuge:widget', component: <VehicleDashboardCard /> },
{ key: 'equipment', widgetKey: 'equipment', permission: 'ausruestung:widget', component: <EquipmentDashboardCard /> },
{ key: 'atemschutz', widgetKey: 'atemschutz', permission: 'atemschutz:widget', component: <AtemschutzDashboardCard /> },
{ key: 'adminStatus', widgetKey: 'adminStatus', permission: 'admin:view', component: <AdminStatusWidget /> },
{ key: 'bestellungen', widgetKey: 'bestellungen', permission: 'bestellungen:widget', component: <BestellungenWidget /> },
{ key: 'ausruestungsanfragen', widgetKey: 'ausruestungsanfragen', permission: 'ausruestungsanfrage:widget', component: <AusruestungsanfrageWidget /> },
{ key: 'issueOverview', widgetKey: 'issueOverview', permission: 'issues:view_all', component: <IssueOverviewWidget /> },
{ key: 'checklistOverdue', widgetKey: 'checklistOverdue', permission: 'checklisten:widget', component: <ChecklistWidget /> },
],
kalender: [
{ key: 'events', widgetKey: 'events', permission: 'kalender:view', component: <UpcomingEventsWidget /> },
{ key: 'vehicleBookingList', widgetKey: 'vehicleBookingList', permission: 'fahrzeugbuchungen:view', component: <VehicleBookingListWidget /> },
{ key: 'vehicleBooking', widgetKey: 'vehicleBooking', permission: 'fahrzeugbuchungen:manage', component: <VehicleBookingQuickAddWidget /> },
{ key: 'eventQuickAdd', widgetKey: 'eventQuickAdd', permission: 'kalender:create', component: <EventQuickAddWidget /> },
],
dienste: [
{ key: 'bookstackRecent', widgetKey: 'bookstackRecent', permission: 'wissen:view', component: <BookStackRecentWidget /> },
{ key: 'bookstackSearch', widgetKey: 'bookstackSearch', permission: 'wissen:view', component: <BookStackSearchWidget /> },
{ key: 'vikunjaTasks', widgetKey: 'vikunjaTasks', permission: 'vikunja:widget_tasks', component: <VikunjaMyTasksWidget /> },
{ key: 'vikunjaQuickAdd', widgetKey: 'vikunjaQuickAdd', permission: 'vikunja:widget_quick_add', component: <VikunjaQuickAddWidget /> },
{ key: 'issueQuickAdd', widgetKey: 'issueQuickAdd', permission: 'issues:widget', component: <IssueQuickAddWidget /> },
],
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: <BannerWidget /> },
],
}), []);
// Widget order from preferences, falling back to defaults
const [localOrder, setLocalOrder] = useState<Record<GroupName, string[]>>(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(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setDataLoading(false); setDataLoading(false);
}, 800); }, 800);
return () => clearTimeout(timer); 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 (
<WidgetGroup title="Information" gridColumn="1 / -1" key="information">
{linksVisible && linkCollections.map((collection) => (
<Fade key={collection.id} in={!dataLoading} timeout={600} style={{ transitionDelay: nextDelay() }}>
<Box>
<LinksWidget collection={collection} />
</Box>
</Fade>
))}
{bannerVisible && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: nextDelay() }}>
<Box>
<BannerWidget />
</Box>
</Fade>
)}
</WidgetGroup>
);
}
if (keys.length === 0) return null;
return (
<WidgetGroup
title={GROUP_ORDER.find((g) => g.name === group)!.title}
gridColumn="1 / -1"
key={group}
>
<SortableContext items={keys} strategy={rectSortingStrategy}>
{visible.map((def) => {
const delay = nextDelay();
return (
<SortableWidget key={def.key} id={def.key} editMode={editMode}>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: delay }}>
<Box>{def.component}</Box>
</Fade>
</SortableWidget>
);
})}
</SortableContext>
</WidgetGroup>
);
};
return ( return (
<DashboardLayout> <DashboardLayout>
{/* Vikunja — Overdue Notifier (invisible, polling component — outside grid) */}
<VikunjaOverdueNotifier /> <VikunjaOverdueNotifier />
{/* Atemschutz — Expiry Notifier (invisible, polling component — outside grid) */}
<AtemschutzExpiryNotifier /> <AtemschutzExpiryNotifier />
{/* Edit mode toggle */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1, px: 1 }}>
<Tooltip title={editMode ? 'Bearbeitung beenden' : 'Widgets anordnen'}>
<IconButton
size="small"
onClick={() => setEditMode((prev) => !prev)}
color={editMode ? 'primary' : 'default'}
>
{editMode ? <CheckIcon /> : <EditIcon />}
</IconButton>
</Tooltip>
</Box>
<Container maxWidth={false} disableGutters> <Container maxWidth={false} disableGutters>
<Box <DndContext
sx={{ sensors={sensors}
display: 'grid', collisionDetection={closestCenter}
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', onDragEnd={handleDragEnd}
gap: 2.5,
alignItems: 'start',
}}
> >
{/* Announcement Banner — spans full width, renders null when no banners */} <Box
<AnnouncementBanner gridColumn="1 / -1" /> sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: 2.5,
alignItems: 'start',
}}
>
<AnnouncementBanner gridColumn="1 / -1" />
{/* User Profile Card — full width, contains welcome greeting */} {user && (
{user && ( <Box sx={{ gridColumn: '1 / -1' }}>
<Box sx={{ gridColumn: '1 / -1' }}> {dataLoading ? (
{dataLoading ? ( <SkeletonCard variant="detailed" />
<SkeletonCard variant="detailed" /> ) : (
) : ( <Fade in={true} timeout={600} style={{ transitionDelay: '100ms' }}>
<Fade in={true} timeout={600} style={{ transitionDelay: '100ms' }}> <Box>
<Box> <UserProfile user={user} />
<UserProfile user={user} /> </Box>
</Box> </Fade>
</Fade> )}
)} </Box>
</Box>
)}
{/* Status Group */}
<WidgetGroup title="Status" gridColumn="1 / -1">
{hasPermission('fahrzeuge:widget') && widgetVisible('vehicles') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '300ms' }}>
<Box>
<VehicleDashboardCard />
</Box>
</Fade>
)} )}
{hasPermission('ausruestung:widget') && widgetVisible('equipment') && ( {GROUP_ORDER.map((g) => renderGroup(g.name))}
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '350ms' }}> </Box>
<Box> </DndContext>
<EquipmentDashboardCard />
</Box>
</Fade>
)}
{hasPermission('atemschutz:widget') && widgetVisible('atemschutz') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '400ms' }}>
<Box>
<AtemschutzDashboardCard />
</Box>
</Fade>
)}
{hasPermission('admin:view') && widgetVisible('adminStatus') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '440ms' }}>
<Box>
<AdminStatusWidget />
</Box>
</Fade>
)}
{hasPermission('bestellungen:widget') && widgetVisible('bestellungen') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '460ms' }}>
<Box>
<BestellungenWidget />
</Box>
</Fade>
)}
{hasPermission('ausruestungsanfrage:widget') && widgetVisible('ausruestungsanfragen') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '470ms' }}>
<Box>
<AusruestungsanfrageWidget />
</Box>
</Fade>
)}
{hasPermission('issues:view_all') && widgetVisible('issueOverview') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '480ms' }}>
<Box>
<IssueOverviewWidget />
</Box>
</Fade>
)}
</WidgetGroup>
{/* Kalender Group */}
<WidgetGroup title="Kalender" gridColumn="1 / -1">
{hasPermission('kalender:view') && widgetVisible('events') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '480ms' }}>
<Box>
<UpcomingEventsWidget />
</Box>
</Fade>
)}
{hasPermission('fahrzeugbuchungen:view') && widgetVisible('vehicleBookingList') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '520ms' }}>
<Box>
<VehicleBookingListWidget />
</Box>
</Fade>
)}
{hasPermission('fahrzeugbuchungen:manage') && widgetVisible('vehicleBooking') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '560ms' }}>
<Box>
<VehicleBookingQuickAddWidget />
</Box>
</Fade>
)}
{hasPermission('kalender:create') && widgetVisible('eventQuickAdd') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '600ms' }}>
<Box>
<EventQuickAddWidget />
</Box>
</Fade>
)}
</WidgetGroup>
{/* Dienste Group */}
<WidgetGroup title="Dienste" gridColumn="1 / -1">
{hasPermission('wissen:view') && widgetVisible('bookstackRecent') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '600ms' }}>
<Box>
<BookStackRecentWidget />
</Box>
</Fade>
)}
{hasPermission('wissen:view') && widgetVisible('bookstackSearch') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '640ms' }}>
<Box>
<BookStackSearchWidget />
</Box>
</Fade>
)}
{hasPermission('vikunja:widget_tasks') && widgetVisible('vikunjaTasks') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '680ms' }}>
<Box>
<VikunjaMyTasksWidget />
</Box>
</Fade>
)}
{hasPermission('vikunja:widget_quick_add') && widgetVisible('vikunjaQuickAdd') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '720ms' }}>
<Box>
<VikunjaQuickAddWidget />
</Box>
</Fade>
)}
{hasPermission('issues:widget') && widgetVisible('issueQuickAdd') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '760ms' }}>
<Box>
<IssueQuickAddWidget />
</Box>
</Fade>
)}
</WidgetGroup>
{/* Information Group */}
<WidgetGroup title="Information" gridColumn="1 / -1">
{hasPermission('dashboard:widget_links') && widgetVisible('links') && linkCollections.map((collection, idx) => (
<Fade key={collection.id} in={!dataLoading} timeout={600} style={{ transitionDelay: `${760 + idx * 40}ms` }}>
<Box>
<LinksWidget collection={collection} />
</Box>
</Fade>
))}
{hasPermission('dashboard:widget_banner') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: `${760 + linkCollections.length * 40}ms` }}>
<Box>
<BannerWidget />
</Box>
</Fade>
)}
</WidgetGroup>
</Box>
</Container> </Container>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -77,7 +77,9 @@ import {
import type { AusruestungListItem } from '../types/equipment.types'; import type { AusruestungListItem } from '../types/equipment.types';
import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types'; import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import FahrzeugChecklistTab from '../components/fahrzeuge/FahrzeugChecklistTab';
// ── Tab Panel ───────────────────────────────────────────────────────────────── // ── Tab Panel ─────────────────────────────────────────────────────────────────
@@ -880,6 +882,7 @@ function FahrzeugDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { isAdmin, canChangeStatus, canManageMaintenance } = usePermissions(); const { isAdmin, canChangeStatus, canManageMaintenance } = usePermissions();
const { hasPermission } = usePermissionContext();
const notification = useNotification(); const notification = useNotification();
const [vehicle, setVehicle] = useState<FahrzeugDetailType | null>(null); const [vehicle, setVehicle] = useState<FahrzeugDetailType | null>(null);
@@ -1035,6 +1038,7 @@ function FahrzeugDetail() {
/> />
<Tab label="Einsätze" /> <Tab label="Einsätze" />
<Tab label={`Ausrüstung${vehicleEquipment.length > 0 ? ` (${vehicleEquipment.length})` : ''}`} /> <Tab label={`Ausrüstung${vehicleEquipment.length > 0 ? ` (${vehicleEquipment.length})` : ''}`} />
{hasPermission('checklisten:view') && <Tab label="Checklisten" />}
</Tabs> </Tabs>
</Box> </Box>
@@ -1071,6 +1075,12 @@ function FahrzeugDetail() {
<AusruestungTab equipment={vehicleEquipment} vehicleId={vehicle.id} /> <AusruestungTab equipment={vehicleEquipment} vehicleId={vehicle.id} />
</TabPanel> </TabPanel>
{hasPermission('checklisten:view') && (
<TabPanel value={activeTab} index={4}>
<FahrzeugChecklistTab fahrzeugId={vehicle.id} />
</TabPanel>
)}
{/* Delete confirmation dialog */} {/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onClose={() => !deleteLoading && setDeleteDialogOpen(false)}> <Dialog open={deleteDialogOpen} onClose={() => !deleteLoading && setDeleteDialogOpen(false)}>
<DialogTitle>Fahrzeug löschen</DialogTitle> <DialogTitle>Fahrzeug löschen</DialogTitle>

View File

@@ -3,11 +3,14 @@ import {
Box, Typography, Paper, Chip, IconButton, Button, Dialog, DialogTitle, Box, Typography, Paper, Chip, IconButton, Button, Dialog, DialogTitle,
DialogContent, DialogActions, TextField, MenuItem, Select, FormControl, DialogContent, DialogActions, TextField, MenuItem, Select, FormControl,
InputLabel, Divider, CircularProgress, Autocomplete, Grid, Card, CardContent, InputLabel, Divider, CircularProgress, Autocomplete, Grid, Card, CardContent,
List, ListItem, ListItemIcon, ListItemText, ListItemSecondaryAction,
} from '@mui/material'; } from '@mui/material';
import { import {
ArrowBack, Delete as DeleteIcon, ArrowBack, Delete as DeleteIcon,
BugReport, FiberNew, HelpOutline, Send as SendIcon, BugReport, FiberNew, HelpOutline, Send as SendIcon,
Circle as CircleIcon, Refresh as RefreshIcon, History, Circle as CircleIcon, Refresh as RefreshIcon, History,
AttachFile as AttachFileIcon, InsertDriveFile as FileIcon,
Upload as UploadIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
@@ -16,13 +19,29 @@ import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { issuesApi } from '../services/issues'; 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) ── // ── Helpers (copied from Issues.tsx) ──
const formatDate = (iso?: string) => 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' }) : '-'; 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) => const formatIssueId = (issue: Issue) =>
`${new Date(issue.created_at).getFullYear()}/${issue.id}`; `${new Date(issue.created_at).getFullYear()}/${issue.id}`;
@@ -123,6 +142,12 @@ export default function IssueDetail() {
enabled: !isNaN(issueId), enabled: !isNaN(issueId),
}); });
const { data: files = [] } = useQuery<IssueDatei[]>({
queryKey: ['issues', issueId, 'files'],
queryFn: () => issuesApi.getFiles(issueId),
enabled: !isNaN(issueId),
});
// ── Permissions ── // ── Permissions ──
const isOwner = issue?.erstellt_von === userId; const isOwner = issue?.erstellt_von === userId;
const isAssignee = issue?.zugewiesen_an === userId; const isAssignee = issue?.zugewiesen_an === userId;
@@ -175,6 +200,30 @@ export default function IssueDetail() {
onError: () => showError('Kommentar konnte nicht erstellt werden'), 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<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) uploadFileMut.mutate(file);
e.target.value = '';
};
const handleReopen = () => { const handleReopen = () => {
updateMut.mutate({ status: initialStatusKey, kommentar: reopenComment.trim() }, { updateMut.mutate({ status: initialStatusKey, kommentar: reopenComment.trim() }, {
onSuccess: () => { onSuccess: () => {
@@ -275,6 +324,20 @@ export default function IssueDetail() {
</CardContent> </CardContent>
</Card> </Card>
</Grid> </Grid>
<Grid item xs={6} sm={4} md={2}>
<Card variant="outlined" sx={issue.faellig_am && new Date(issue.faellig_am) < new Date() ? { borderColor: 'error.main' } : undefined}>
<CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Typography variant="caption" color="text.secondary">Fällig am</Typography>
<Typography
variant="body2"
sx={{ mt: 0.5 }}
color={issue.faellig_am && new Date(issue.faellig_am) < new Date() ? 'error' : 'text.primary'}
>
{formatDateOnly(issue.faellig_am)}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid> </Grid>
{/* Description */} {/* Description */}
@@ -285,6 +348,57 @@ export default function IssueDetail() {
</Paper> </Paper>
)} )}
{/* Attachments */}
<Paper variant="outlined" sx={{ p: 2, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AttachFileIcon fontSize="small" />
<Typography variant="subtitle2">Anhänge ({files.length})</Typography>
</Box>
{canComment && (
<Button
size="small"
startIcon={<UploadIcon />}
component="label"
disabled={uploadFileMut.isPending}
>
{uploadFileMut.isPending ? 'Hochladen...' : 'Datei hochladen'}
<input type="file" hidden onChange={handleFileUpload} />
</Button>
)}
</Box>
{files.length === 0 ? (
<Typography variant="body2" color="text.secondary">Keine Anhänge</Typography>
) : (
<List dense disablePadding>
{files.map((f) => (
<ListItem key={f.id} disableGutters>
<ListItemIcon sx={{ minWidth: 36 }}>
<FileIcon fontSize="small" />
</ListItemIcon>
<ListItemText
primary={f.dateiname}
secondary={`${formatFileSize(f.dateigroesse)}${formatDate(f.hochgeladen_am)}`}
/>
<ListItemSecondaryAction>
{(hasEdit || isOwner) && (
<IconButton
size="small"
edge="end"
color="error"
onClick={() => deleteFileMut.mutate(f.id)}
disabled={deleteFileMut.isPending}
>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
</Paper>
{/* Controls row */} {/* Controls row */}
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}> <Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
{/* Status control */} {/* Status control */}
@@ -341,6 +455,19 @@ export default function IssueDetail() {
isOptionEqualToValue={(o, v) => o.id === v.id} isOptionEqualToValue={(o, v) => o.id === v.id}
/> />
)} )}
{/* Due date */}
{hasEdit && (
<TextField
size="small"
label="Fällig am"
type="date"
value={toInputDate(issue.faellig_am)}
onChange={(e) => updateMut.mutate({ faellig_am: e.target.value || null })}
InputLabelProps={{ shrink: true }}
sx={{ minWidth: 160 }}
/>
)}
</Box> </Box>
{/* Delete button */} {/* Delete button */}

View File

@@ -121,6 +121,16 @@ export default function IssueNeu() {
</Select> </Select>
</FormControl> </FormControl>
</Grid> </Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Fällig am"
type="date"
fullWidth
value={form.faellig_am || ''}
onChange={(e) => setForm({ ...form, faellig_am: e.target.value || null })}
InputLabelProps={{ shrink: true }}
/>
</Grid>
</Grid> </Grid>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 1 }}> <Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 1 }}>

View File

@@ -4,13 +4,14 @@ import {
TableHead, TableRow, Paper, Chip, IconButton, Button, Dialog, DialogTitle, TableHead, TableRow, Paper, Chip, IconButton, Button, Dialog, DialogTitle,
DialogContent, DialogActions, TextField, MenuItem, Select, FormControl, DialogContent, DialogActions, TextField, MenuItem, Select, FormControl,
InputLabel, CircularProgress, FormControlLabel, Switch, InputLabel, CircularProgress, FormControlLabel, Switch,
Autocomplete, Autocomplete, ToggleButtonGroup, ToggleButton,
} from '@mui/material'; } from '@mui/material';
import { import {
Add as AddIcon, Delete as DeleteIcon, Add as AddIcon, Delete as DeleteIcon,
BugReport, FiberNew, HelpOutline, BugReport, FiberNew, HelpOutline,
Circle as CircleIcon, Edit as EditIcon, Circle as CircleIcon, Edit as EditIcon,
DragIndicator, Check as CheckIcon, Close as CloseIcon, DragIndicator, Check as CheckIcon, Close as CloseIcon,
ViewList as ViewListIcon, ViewKanban as ViewKanbanIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSearchParams, useNavigate } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
@@ -21,6 +22,7 @@ import { usePermissionContext } from '../contexts/PermissionContext';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { issuesApi } from '../services/issues'; import { issuesApi } from '../services/issues';
import type { Issue, IssueTyp, IssueFilters, AssignableMember, IssueStatusDef, IssuePriorityDef } from '../types/issue.types'; import type { Issue, IssueTyp, IssueFilters, AssignableMember, IssueStatusDef, IssuePriorityDef } from '../types/issue.types';
import KanbanBoard from '../components/issues/KanbanBoard';
// ── Helpers ── // ── Helpers ──
@@ -555,8 +557,10 @@ function IssueSettings() {
export default function Issues() { export default function Issues() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient();
const { hasPermission } = usePermissionContext(); const { hasPermission } = usePermissionContext();
const { user } = useAuth(); const { user } = useAuth();
const { showSuccess, showError } = useNotification();
const canViewAll = hasPermission('issues:view_all'); const canViewAll = hasPermission('issues:view_all');
const hasEdit = hasPermission('issues:edit'); const hasEdit = hasPermission('issues:edit');
@@ -581,6 +585,27 @@ export default function Issues() {
const [showDoneMine, setShowDoneMine] = useState(false); const [showDoneMine, setShowDoneMine] = useState(false);
const [showDoneAssigned, setShowDoneAssigned] = useState(false); const [showDoneAssigned, setShowDoneAssigned] = useState(false);
const [filters, setFilters] = useState<IssueFilters>({}); const [filters, setFilters] = useState<IssueFilters>({});
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 // Fetch all issues for mine/assigned tabs
const { data: issues = [], isLoading } = useQuery({ const { data: issues = [], isLoading } = useQuery({
@@ -634,7 +659,22 @@ export default function Issues() {
return ( return (
<DashboardLayout> <DashboardLayout>
<Box sx={{ p: 3 }}> <Box sx={{ p: 3 }}>
<Typography variant="h5" gutterBottom>Issues</Typography> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="h5">Issues</Typography>
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={handleViewModeChange}
size="small"
>
<ToggleButton value="list" aria-label="Listenansicht">
<ViewListIcon fontSize="small" />
</ToggleButton>
<ToggleButton value="kanban" aria-label="Kanban-Ansicht">
<ViewKanbanIcon fontSize="small" />
</ToggleButton>
</ToggleButtonGroup>
</Box>
<Tabs value={tab} onChange={handleTabChange} sx={{ mb: 0 }}> <Tabs value={tab} onChange={handleTabChange} sx={{ mb: 0 }}>
{tabs.map((t, i) => <Tab key={i} label={t.label} />)} {tabs.map((t, i) => <Tab key={i} label={t.label} />)}
@@ -642,13 +682,24 @@ export default function Issues() {
{/* Tab 0: Meine Issues */} {/* Tab 0: Meine Issues */}
<TabPanel value={tab} index={0}> <TabPanel value={tab} index={0}>
<FormControlLabel <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
control={<Switch checked={showDoneMine} onChange={(e) => setShowDoneMine(e.target.checked)} size="small" />} {viewMode === 'list' && (
label="Erledigte anzeigen" <FormControlLabel
sx={{ mb: 1 }} control={<Switch checked={showDoneMine} onChange={(e) => setShowDoneMine(e.target.checked)} size="small" />}
/> label="Erledigte anzeigen"
/>
)}
</Box>
{isLoading ? ( {isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box> <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : viewMode === 'kanban' ? (
<KanbanBoard
issues={myIssuesFiltered}
statuses={issueStatuses}
priorities={issuePriorities}
onNavigate={(id) => navigate(`/issues/${id}`)}
onStatusChange={(id, status) => updateStatusMut.mutate({ id, status })}
/>
) : ( ) : (
<IssueTable issues={myIssuesFiltered} statuses={issueStatuses} priorities={issuePriorities} /> <IssueTable issues={myIssuesFiltered} statuses={issueStatuses} priorities={issuePriorities} />
)} )}
@@ -656,13 +707,24 @@ export default function Issues() {
{/* Tab 1: Zugewiesene Issues */} {/* Tab 1: Zugewiesene Issues */}
<TabPanel value={tab} index={1}> <TabPanel value={tab} index={1}>
<FormControlLabel <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
control={<Switch checked={showDoneAssigned} onChange={(e) => setShowDoneAssigned(e.target.checked)} size="small" />} {viewMode === 'list' && (
label="Erledigte anzeigen" <FormControlLabel
sx={{ mb: 1 }} control={<Switch checked={showDoneAssigned} onChange={(e) => setShowDoneAssigned(e.target.checked)} size="small" />}
/> label="Erledigte anzeigen"
/>
)}
</Box>
{isLoading ? ( {isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box> <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : viewMode === 'kanban' ? (
<KanbanBoard
issues={assignedFiltered}
statuses={issueStatuses}
priorities={issuePriorities}
onNavigate={(id) => navigate(`/issues/${id}`)}
onStatusChange={(id, status) => updateStatusMut.mutate({ id, status })}
/>
) : ( ) : (
<IssueTable issues={assignedFiltered} statuses={issueStatuses} priorities={issuePriorities} /> <IssueTable issues={assignedFiltered} statuses={issueStatuses} priorities={issuePriorities} />
)} )}
@@ -674,6 +736,14 @@ export default function Issues() {
<FilterBar filters={filters} onChange={setFilters} types={types} members={members} statuses={issueStatuses} priorities={issuePriorities} /> <FilterBar filters={filters} onChange={setFilters} types={types} members={members} statuses={issueStatuses} priorities={issuePriorities} />
{isFilteredLoading ? ( {isFilteredLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box> <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : viewMode === 'kanban' ? (
<KanbanBoard
issues={filteredIssues}
statuses={issueStatuses}
priorities={issuePriorities}
onNavigate={(id) => navigate(`/issues/${id}`)}
onStatusChange={(id, status) => updateStatusMut.mutate({ id, status })}
/>
) : ( ) : (
<IssueTable issues={filteredIssues} statuses={issueStatuses} priorities={issuePriorities} /> <IssueTable issues={filteredIssues} statuses={issueStatuses} priorities={issuePriorities} />
)} )}

View File

@@ -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<ChecklistVorlage[]> => {
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<ChecklistVorlage> => {
const r = await api.get(`/api/checklisten/vorlagen/${id}`);
return r.data.data;
},
createVorlage: async (data: CreateVorlagePayload): Promise<ChecklistVorlage> => {
const r = await api.post('/api/checklisten/vorlagen', data);
return r.data.data;
},
updateVorlage: async (id: number, data: UpdateVorlagePayload): Promise<ChecklistVorlage> => {
const r = await api.put(`/api/checklisten/vorlagen/${id}`, data);
return r.data.data;
},
deleteVorlage: async (id: number): Promise<void> => {
await api.delete(`/api/checklisten/vorlagen/${id}`);
},
// ── Vorlage Items ──
getVorlageItems: async (vorlageId: number): Promise<ChecklistVorlageItem[]> => {
const r = await api.get(`/api/checklisten/vorlagen/${vorlageId}/items`);
return r.data.data;
},
addVorlageItem: async (vorlageId: number, data: CreateVorlageItemPayload): Promise<ChecklistVorlageItem> => {
const r = await api.post(`/api/checklisten/vorlagen/${vorlageId}/items`, data);
return r.data.data;
},
updateVorlageItem: async (id: number, data: UpdateVorlageItemPayload): Promise<ChecklistVorlageItem> => {
const r = await api.put(`/api/checklisten/items/${id}`, data);
return r.data.data;
},
deleteVorlageItem: async (id: number): Promise<void> => {
await api.delete(`/api/checklisten/items/${id}`);
},
// ── Vehicle-specific Items ──
getVehicleItems: async (fahrzeugId: string): Promise<FahrzeugChecklistItem[]> => {
const r = await api.get(`/api/checklisten/fahrzeug/${fahrzeugId}/items`);
return r.data.data;
},
addVehicleItem: async (fahrzeugId: string, data: CreateFahrzeugItemPayload): Promise<FahrzeugChecklistItem> => {
const r = await api.post(`/api/checklisten/fahrzeug/${fahrzeugId}/items`, data);
return r.data.data;
},
updateVehicleItem: async (id: number, data: UpdateFahrzeugItemPayload): Promise<FahrzeugChecklistItem> => {
const r = await api.put(`/api/checklisten/fahrzeug-items/${id}`, data);
return r.data.data;
},
deleteVehicleItem: async (id: number): Promise<void> => {
await api.delete(`/api/checklisten/fahrzeug-items/${id}`);
},
// ── Checklists for a Vehicle ──
getChecklistenForVehicle: async (fahrzeugId: string): Promise<ChecklistVorlage[]> => {
const r = await api.get(`/api/checklisten/fahrzeug/${fahrzeugId}/checklisten`);
return r.data.data;
},
// ── Executions ──
startExecution: async (fahrzeugId: string, vorlageId: number): Promise<ChecklistAusfuehrung> => {
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<ChecklistAusfuehrung> => {
const r = await api.put(`/api/checklisten/ausfuehrungen/${id}`, data);
return r.data.data;
},
approveExecution: async (id: string): Promise<ChecklistAusfuehrung> => {
const r = await api.post(`/api/checklisten/ausfuehrungen/${id}/freigabe`);
return r.data.data;
},
getExecution: async (id: string): Promise<ChecklistAusfuehrung> => {
const r = await api.get(`/api/checklisten/ausfuehrungen/${id}`);
return r.data.data;
},
getExecutions: async (filter?: ChecklistAusfuehrungFilter): Promise<ChecklistAusfuehrung[]> => {
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<ChecklistFaelligkeit[]> => {
const r = await api.get('/api/checklisten/faellig');
return r.data.data;
},
};

View File

@@ -0,0 +1,28 @@
import { api } from './api';
import type { FahrzeugTyp } from '../types/checklist.types';
export const fahrzeugTypenApi = {
getAll: async (): Promise<FahrzeugTyp[]> => {
const r = await api.get('/api/fahrzeug-typen');
return r.data.data;
},
getById: async (id: number): Promise<FahrzeugTyp> => {
const r = await api.get(`/api/fahrzeug-typen/${id}`);
return r.data.data;
},
create: async (data: Partial<FahrzeugTyp>): Promise<FahrzeugTyp> => {
const r = await api.post('/api/fahrzeug-typen', data);
return r.data.data;
},
update: async (id: number, data: Partial<FahrzeugTyp>): Promise<FahrzeugTyp> => {
const r = await api.put(`/api/fahrzeug-typen/${id}`, data);
return r.data.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`/api/fahrzeug-typen/${id}`);
},
};

View File

@@ -1,5 +1,5 @@
import { api } from './api'; 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 = { export const issuesApi = {
getIssues: async (filters?: IssueFilters): Promise<Issue[]> => { getIssues: async (filters?: IssueFilters): Promise<Issue[]> => {
@@ -98,4 +98,20 @@ export const issuesApi = {
deletePriority: async (id: number): Promise<void> => { deletePriority: async (id: number): Promise<void> => {
await api.delete(`/api/issues/priorities/${id}`); await api.delete(`/api/issues/priorities/${id}`);
}, },
// Files
uploadFile: async (issueId: number, file: File): Promise<IssueDatei> => {
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<IssueDatei[]> => {
const r = await api.get(`/api/issues/${issueId}/files`);
return r.data.data;
},
deleteFile: async (fileId: string): Promise<void> => {
await api.delete(`/api/issues/files/${fileId}`);
},
}; };

View File

@@ -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<ChecklistAusfuehrungStatus, string> = {
offen: 'Offen',
abgeschlossen: 'Abgeschlossen',
unvollstaendig: 'Unvollst\u00e4ndig',
freigegeben: 'Freigegeben',
};
export const CHECKLIST_STATUS_COLORS: Record<ChecklistAusfuehrungStatus, 'default' | 'warning' | 'success' | 'info'> = {
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[];
}

View File

@@ -25,6 +25,7 @@ export interface Issue {
erstellt_von_name?: string; erstellt_von_name?: string;
zugewiesen_an: string | null; zugewiesen_an: string | null;
zugewiesen_an_name?: string | null; zugewiesen_an_name?: string | null;
faellig_am: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -53,6 +54,7 @@ export interface CreateIssuePayload {
beschreibung?: string; beschreibung?: string;
typ_id?: number; typ_id?: number;
prioritaet?: string; prioritaet?: string;
faellig_am?: string | null;
} }
export interface UpdateIssuePayload { export interface UpdateIssuePayload {
@@ -63,6 +65,7 @@ export interface UpdateIssuePayload {
status?: string; status?: string;
zugewiesen_an?: string | null; zugewiesen_an?: string | null;
kommentar?: string; kommentar?: string;
faellig_am?: string | null;
} }
export interface IssueFilters { export interface IssueFilters {
@@ -108,3 +111,14 @@ export interface IssueStatusCount {
} }
export type IssueWidgetSummary = 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;
}