feat: add issue kanban/attachments/deadlines, dashboard widget DnD, and checklisten system
This commit is contained in:
@@ -104,6 +104,8 @@ import permissionRoutes from './routes/permission.routes';
|
||||
import ausruestungsanfrageRoutes from './routes/ausruestungsanfrage.routes';
|
||||
import issueRoutes from './routes/issue.routes';
|
||||
import buchungskategorieRoutes from './routes/buchungskategorie.routes';
|
||||
import checklistRoutes from './routes/checklist.routes';
|
||||
import fahrzeugTypRoutes from './routes/fahrzeugTyp.routes';
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/user', userRoutes);
|
||||
@@ -130,6 +132,8 @@ app.use('/api/permissions', permissionRoutes);
|
||||
app.use('/api/ausruestungsanfragen', ausruestungsanfrageRoutes);
|
||||
app.use('/api/issues', issueRoutes);
|
||||
app.use('/api/buchungskategorien', buchungskategorieRoutes);
|
||||
app.use('/api/checklisten', checklistRoutes);
|
||||
app.use('/api/fahrzeug-typen', fahrzeugTypRoutes);
|
||||
|
||||
// Static file serving for uploads (authenticated)
|
||||
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');
|
||||
|
||||
383
backend/src/controllers/checklist.controller.ts
Normal file
383
backend/src/controllers/checklist.controller.ts
Normal 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();
|
||||
126
backend/src/controllers/fahrzeugTyp.controller.ts
Normal file
126
backend/src/controllers/fahrzeugTyp.controller.ts
Normal 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();
|
||||
@@ -175,6 +175,7 @@ class IssueController {
|
||||
titel: 'Titel geändert',
|
||||
beschreibung: 'Beschreibung geändert',
|
||||
typ_id: 'Typ geändert',
|
||||
faellig_am: 'Fälligkeitsdatum geändert',
|
||||
};
|
||||
for (const [field, label] of Object.entries(fieldLabels)) {
|
||||
if (field in updateData && updateData[field] !== existing[field]) {
|
||||
@@ -503,6 +504,67 @@ class IssueController {
|
||||
res.status(500).json({ success: false, message: 'Priorität konnte nicht deaktiviert werden' });
|
||||
}
|
||||
}
|
||||
|
||||
// --- File management ---
|
||||
|
||||
async uploadFile(req: Request, res: Response): Promise<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();
|
||||
|
||||
@@ -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);
|
||||
46
backend/src/database/migrations/067_fahrzeug_typen.sql
Normal file
46
backend/src/database/migrations/067_fahrzeug_typen.sql
Normal 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;
|
||||
113
backend/src/database/migrations/068_checklisten.sql
Normal file
113
backend/src/database/migrations/068_checklisten.sql
Normal 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 $$;
|
||||
@@ -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;
|
||||
85
backend/src/jobs/checklist-reminder.job.ts
Normal file
85
backend/src/jobs/checklist-reminder.job.ts
Normal 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');
|
||||
}
|
||||
71
backend/src/jobs/issue-reminder.job.ts
Normal file
71
backend/src/jobs/issue-reminder.job.ts
Normal 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');
|
||||
}
|
||||
@@ -94,4 +94,42 @@ const wartungOptions: any = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const uploadWartung: any = multer(wartungOptions);
|
||||
|
||||
export { UPLOAD_DIR, THUMBNAIL_DIR, WARTUNG_DIR };
|
||||
// ── Issue uploads ────────────────────────────────────────────────────────────
|
||||
|
||||
const ISSUE_DIR = path.join(APP_ROOT, 'uploads', 'issues');
|
||||
try {
|
||||
if (!fs.existsSync(ISSUE_DIR)) {
|
||||
fs.mkdirSync(ISSUE_DIR, { recursive: true });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Could not create issue upload directory`, { err });
|
||||
}
|
||||
|
||||
const issueStorage = multer.diskStorage({
|
||||
destination(_req: any, _file: any, cb: any) {
|
||||
cb(null, ISSUE_DIR);
|
||||
},
|
||||
filename(_req: any, file: any, cb: any) {
|
||||
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${uniqueSuffix}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const issueOptions: any = {
|
||||
storage: issueStorage,
|
||||
fileFilter(_req: any, file: any, cb: any) {
|
||||
if (ALLOWED_TYPES.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error(`Dateityp ${file.mimetype} ist nicht erlaubt.`));
|
||||
}
|
||||
},
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const uploadIssue: any = multer(issueOptions);
|
||||
|
||||
export { UPLOAD_DIR, THUMBNAIL_DIR, WARTUNG_DIR, ISSUE_DIR };
|
||||
|
||||
163
backend/src/routes/checklist.routes.ts
Normal file
163
backend/src/routes/checklist.routes.ts
Normal 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;
|
||||
58
backend/src/routes/fahrzeugTyp.routes.ts
Normal file
58
backend/src/routes/fahrzeugTyp.routes.ts
Normal 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;
|
||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
||||
import issueController from '../controllers/issue.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import { requirePermission } from '../middleware/rbac.middleware';
|
||||
import { uploadIssue } from '../middleware/upload';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -60,6 +61,13 @@ router.get(
|
||||
issueController.getMembers.bind(issueController)
|
||||
);
|
||||
|
||||
// --- File management (BEFORE /:id to avoid conflict) ---
|
||||
router.delete(
|
||||
'/files/:fileId',
|
||||
authenticate,
|
||||
issueController.deleteFile.bind(issueController)
|
||||
);
|
||||
|
||||
// --- Issue CRUD ---
|
||||
router.get(
|
||||
'/',
|
||||
@@ -92,6 +100,19 @@ router.post(
|
||||
issueController.addComment.bind(issueController)
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/files',
|
||||
authenticate,
|
||||
uploadIssue.single('file'),
|
||||
issueController.uploadFile.bind(issueController)
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id/files',
|
||||
authenticate,
|
||||
issueController.getFiles.bind(issueController)
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id',
|
||||
authenticate,
|
||||
|
||||
@@ -5,6 +5,8 @@ import { testConnection, closePool, runMigrations } from './config/database';
|
||||
import { startAuditCleanupJob, stopAuditCleanupJob } from './jobs/audit-cleanup.job';
|
||||
import { startNotificationJob, stopNotificationJob } from './jobs/notification-generation.job';
|
||||
import { startReminderJob, stopReminderJob } from './jobs/reminder.job';
|
||||
import { startIssueReminderJob, stopIssueReminderJob } from './jobs/issue-reminder.job';
|
||||
import { startChecklistReminderJob, stopChecklistReminderJob } from './jobs/checklist-reminder.job';
|
||||
import { permissionService } from './services/permission.service';
|
||||
|
||||
const startServer = async (): Promise<void> => {
|
||||
@@ -32,6 +34,12 @@ const startServer = async (): Promise<void> => {
|
||||
// Start the order reminder job
|
||||
startReminderJob();
|
||||
|
||||
// Start the issue reminder job
|
||||
startIssueReminderJob();
|
||||
|
||||
// Start the checklist reminder job
|
||||
startChecklistReminderJob();
|
||||
|
||||
// Start the server
|
||||
const server = app.listen(environment.port, () => {
|
||||
logger.info('Server started successfully', {
|
||||
@@ -56,6 +64,8 @@ const startServer = async (): Promise<void> => {
|
||||
stopAuditCleanupJob();
|
||||
stopNotificationJob();
|
||||
stopReminderJob();
|
||||
stopIssueReminderJob();
|
||||
stopChecklistReminderJob();
|
||||
|
||||
server.close(async () => {
|
||||
logger.info('HTTP server closed');
|
||||
|
||||
650
backend/src/services/checklist.service.ts
Normal file
650
backend/src/services/checklist.service.ts
Normal 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,
|
||||
};
|
||||
142
backend/src/services/fahrzeugTyp.service.ts
Normal file
142
backend/src/services/fahrzeugTyp.service.ts
Normal 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();
|
||||
@@ -1,5 +1,6 @@
|
||||
import pool from '../config/database';
|
||||
import logger from '../utils/logger';
|
||||
import fs from 'fs';
|
||||
|
||||
interface IssueFilters {
|
||||
typ_id?: number[];
|
||||
@@ -92,13 +93,13 @@ async function getIssueById(id: number) {
|
||||
}
|
||||
|
||||
async function createIssue(
|
||||
data: { titel: string; beschreibung?: string; typ_id?: number; prioritaet?: string },
|
||||
data: { titel: string; beschreibung?: string; typ_id?: number; prioritaet?: string; faellig_am?: string | null },
|
||||
userId: string
|
||||
) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO issues (titel, beschreibung, typ_id, prioritaet, erstellt_von)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`INSERT INTO issues (titel, beschreibung, typ_id, prioritaet, erstellt_von, faellig_am)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.titel,
|
||||
@@ -106,6 +107,7 @@ async function createIssue(
|
||||
data.typ_id || 3,
|
||||
data.prioritaet || 'mittel',
|
||||
userId,
|
||||
data.faellig_am || null,
|
||||
]
|
||||
);
|
||||
return getIssueById(result.rows[0].id);
|
||||
@@ -127,6 +129,7 @@ async function updateIssue(
|
||||
prioritaet?: string;
|
||||
status?: string;
|
||||
zugewiesen_an?: string | null;
|
||||
faellig_am?: string | null;
|
||||
}
|
||||
) {
|
||||
try {
|
||||
@@ -164,6 +167,11 @@ async function updateIssue(
|
||||
values.push(data.zugewiesen_an ?? null);
|
||||
idx++;
|
||||
}
|
||||
if ('faellig_am' in data) {
|
||||
setClauses.push(`faellig_am = $${idx}`);
|
||||
values.push(data.faellig_am ?? null);
|
||||
idx++;
|
||||
}
|
||||
|
||||
if (setClauses.length === 0) {
|
||||
return getIssueById(id);
|
||||
@@ -594,6 +602,92 @@ async function deleteIssuePriority(id: number) {
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Files (Issue Dateien)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function addFile(
|
||||
issueId: number,
|
||||
fileData: { dateiname: string; dateipfad: string; dateityp: string; dateigroesse: number },
|
||||
userId: string
|
||||
) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO issue_dateien (issue_id, dateiname, dateipfad, dateityp, dateigroesse, hochgeladen_von)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[issueId, fileData.dateiname, fileData.dateipfad, fileData.dateityp, fileData.dateigroesse, userId]
|
||||
);
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('IssueService.addFile failed', { error, issueId });
|
||||
throw new Error('Datei konnte nicht gespeichert werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function getFiles(issueId: number) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM issue_dateien WHERE issue_id = $1 ORDER BY hochgeladen_am DESC`,
|
||||
[issueId]
|
||||
);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
logger.error('IssueService.getFiles failed', { error, issueId });
|
||||
throw new Error('Dateien konnten nicht geladen werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFile(fileId: string, userId: string) {
|
||||
try {
|
||||
const fileResult = await pool.query(
|
||||
`SELECT * FROM issue_dateien WHERE id = $1`,
|
||||
[fileId]
|
||||
);
|
||||
if (fileResult.rows.length === 0) return null;
|
||||
|
||||
const file = fileResult.rows[0];
|
||||
await pool.query(`DELETE FROM issue_dateien WHERE id = $1`, [fileId]);
|
||||
|
||||
// Remove from disk
|
||||
try {
|
||||
if (file.dateipfad && fs.existsSync(file.dateipfad)) {
|
||||
fs.unlinkSync(file.dateipfad);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to delete issue file from disk', { path: file.dateipfad, error: err });
|
||||
}
|
||||
|
||||
return { dateipfad: file.dateipfad, dateiname: file.dateiname };
|
||||
} catch (error) {
|
||||
logger.error('IssueService.deleteFile failed', { error, fileId });
|
||||
throw new Error('Datei konnte nicht gelöscht werden');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overdue Issues
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getOverdueIssues() {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT i.*, u1.name AS erstellt_von_name, u2.name AS zugewiesen_an_name
|
||||
FROM issues i
|
||||
LEFT JOIN users u1 ON u1.id = i.erstellt_von
|
||||
LEFT JOIN users u2 ON u2.id = i.zugewiesen_an
|
||||
LEFT JOIN issue_statuses s ON s.schluessel = i.status
|
||||
WHERE i.faellig_am < NOW()
|
||||
AND (s.ist_abschluss = false OR s.ist_abschluss IS NULL)
|
||||
ORDER BY i.faellig_am ASC
|
||||
`);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
logger.error('IssueService.getOverdueIssues failed', { error });
|
||||
throw new Error('Überfällige Issues konnten nicht geladen werden');
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getIssues,
|
||||
getIssueById,
|
||||
@@ -618,5 +712,9 @@ export default {
|
||||
createIssuePriority,
|
||||
updateIssuePriority,
|
||||
deleteIssuePriority,
|
||||
addFile,
|
||||
getFiles,
|
||||
deleteFile,
|
||||
getOverdueIssues,
|
||||
UNASSIGN,
|
||||
};
|
||||
|
||||
@@ -137,6 +137,16 @@ class VehicleService {
|
||||
? { titel: lehrgang.rows[0].titel, beginn: lehrgang.rows[0].beginn, ende: lehrgang.rows[0].ende }
|
||||
: null;
|
||||
|
||||
// Fetch vehicle types via junction table
|
||||
const typenResult = await pool.query(
|
||||
`SELECT ft.* FROM fahrzeug_typen ft
|
||||
JOIN fahrzeug_fahrzeug_typen fft ON fft.fahrzeug_typ_id = ft.id
|
||||
WHERE fft.fahrzeug_id = $1
|
||||
ORDER BY ft.name ASC`,
|
||||
[id]
|
||||
);
|
||||
(vehicle as any).typen = typenResult.rows;
|
||||
|
||||
return vehicle;
|
||||
} catch (error) {
|
||||
logger.error('VehicleService.getVehicleById failed', { error, id });
|
||||
|
||||
62
frontend/package-lock.json
generated
62
frontend/package-lock.json
generated
@@ -8,6 +8,9 @@
|
||||
"name": "feuerwehr-dashboard-frontend",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.14.18",
|
||||
@@ -301,6 +304,60 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/babel-plugin": {
|
||||
"version": "11.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
|
||||
@@ -3222,6 +3279,11 @@
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://npm.apple.com/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.14.18",
|
||||
|
||||
@@ -36,6 +36,8 @@ import AusruestungsanfrageDetail from './pages/AusruestungsanfrageDetail';
|
||||
import AusruestungsanfrageZuBestellung from './pages/AusruestungsanfrageZuBestellung';
|
||||
import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail';
|
||||
import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu';
|
||||
import Checklisten from './pages/Checklisten';
|
||||
import ChecklistAusfuehrung from './pages/ChecklistAusfuehrung';
|
||||
import Issues from './pages/Issues';
|
||||
import IssueDetail from './pages/IssueDetail';
|
||||
import IssueNeu from './pages/IssueNeu';
|
||||
@@ -342,6 +344,22 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/checklisten"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Checklisten />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/checklisten/ausfuehrung/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ChecklistAusfuehrung />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/issues/neu"
|
||||
element={
|
||||
|
||||
@@ -112,6 +112,12 @@ const PERMISSION_SUB_GROUPS: Record<string, Record<string, string[]>> = {
|
||||
'Bearbeiten': ['create', 'change_status', 'edit', 'delete'],
|
||||
'Admin': ['edit_settings'],
|
||||
},
|
||||
checklisten: {
|
||||
'Ansehen': ['view'],
|
||||
'Ausführen': ['execute', 'approve'],
|
||||
'Verwaltung': ['manage_templates'],
|
||||
'Widget': ['widget'],
|
||||
},
|
||||
admin: {
|
||||
'Allgemein': ['view', 'write'],
|
||||
'Services': ['view_services', 'edit_services'],
|
||||
|
||||
92
frontend/src/components/dashboard/ChecklistWidget.tsx
Normal file
92
frontend/src/components/dashboard/ChecklistWidget.tsx
Normal 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;
|
||||
55
frontend/src/components/dashboard/SortableWidget.tsx
Normal file
55
frontend/src/components/dashboard/SortableWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -22,3 +22,5 @@ export { default as BestellungenWidget } from './BestellungenWidget';
|
||||
export { default as AusruestungsanfrageWidget } from './AusruestungsanfrageWidget';
|
||||
export { default as IssueQuickAddWidget } from './IssueQuickAddWidget';
|
||||
export { default as IssueOverviewWidget } from './IssueOverviewWidget';
|
||||
export { default as ChecklistWidget } from './ChecklistWidget';
|
||||
export { default as SortableWidget } from './SortableWidget';
|
||||
|
||||
252
frontend/src/components/fahrzeuge/FahrzeugChecklistTab.tsx
Normal file
252
frontend/src/components/fahrzeuge/FahrzeugChecklistTab.tsx
Normal 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;
|
||||
328
frontend/src/components/issues/KanbanBoard.tsx
Normal file
328
frontend/src/components/issues/KanbanBoard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
BugReport,
|
||||
BookOnline,
|
||||
Forum,
|
||||
AssignmentTurnedIn,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -136,6 +137,12 @@ const baseNavigationItems: NavigationItem[] = [
|
||||
// subItems computed dynamically in navigationItems useMemo
|
||||
permission: 'ausruestungsanfrage:view',
|
||||
},
|
||||
{
|
||||
text: 'Checklisten',
|
||||
icon: <AssignmentTurnedIn />,
|
||||
path: '/checklisten',
|
||||
permission: 'checklisten:view',
|
||||
},
|
||||
{
|
||||
text: 'Issues',
|
||||
icon: <BugReport />,
|
||||
|
||||
@@ -17,6 +17,7 @@ export const WIDGETS = [
|
||||
{ key: 'ausruestungsanfragen', label: 'Interne Bestellungen', defaultVisible: true },
|
||||
{ key: 'issueQuickAdd', label: 'Issue melden', defaultVisible: true },
|
||||
{ key: 'issueOverview', label: 'Issue Übersicht', defaultVisible: true },
|
||||
{ key: 'checklistOverdue', label: 'Checklisten', defaultVisible: true },
|
||||
] as const;
|
||||
|
||||
export type WidgetKey = typeof WIDGETS[number]['key'];
|
||||
|
||||
314
frontend/src/pages/ChecklistAusfuehrung.tsx
Normal file
314
frontend/src/pages/ChecklistAusfuehrung.tsx
Normal 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'} · {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 "OK" 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>
|
||||
);
|
||||
}
|
||||
649
frontend/src/pages/Checklisten.tsx
Normal file
649
frontend/src/pages/Checklisten.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,26 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Box,
|
||||
Fade,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Edit as EditIcon, Check as CheckIcon } from '@mui/icons-material';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCenter,
|
||||
} from '@dnd-kit/core';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
rectSortingStrategy,
|
||||
arrayMove,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
@@ -28,18 +44,52 @@ import EventQuickAddWidget from '../components/dashboard/EventQuickAddWidget';
|
||||
import LinksWidget from '../components/dashboard/LinksWidget';
|
||||
import BannerWidget from '../components/dashboard/BannerWidget';
|
||||
import WidgetGroup from '../components/dashboard/WidgetGroup';
|
||||
import SortableWidget from '../components/dashboard/SortableWidget';
|
||||
import BestellungenWidget from '../components/dashboard/BestellungenWidget';
|
||||
import AusruestungsanfrageWidget from '../components/dashboard/AusruestungsanfrageWidget';
|
||||
import IssueQuickAddWidget from '../components/dashboard/IssueQuickAddWidget';
|
||||
import IssueOverviewWidget from '../components/dashboard/IssueOverviewWidget';
|
||||
import ChecklistWidget from '../components/dashboard/ChecklistWidget';
|
||||
import { preferencesApi } from '../services/settings';
|
||||
import { configApi } from '../services/config';
|
||||
import { WidgetKey } from '../constants/widgets';
|
||||
|
||||
// ── Widget definitions per group ──
|
||||
|
||||
interface WidgetDef {
|
||||
key: string;
|
||||
widgetKey?: WidgetKey;
|
||||
permission?: string;
|
||||
component: React.ReactNode;
|
||||
}
|
||||
|
||||
type GroupName = 'status' | 'kalender' | 'dienste' | 'information';
|
||||
|
||||
const GROUP_ORDER: { name: GroupName; title: string }[] = [
|
||||
{ name: 'status', title: 'Status' },
|
||||
{ name: 'kalender', title: 'Kalender' },
|
||||
{ name: 'dienste', title: 'Dienste' },
|
||||
{ name: 'information', title: 'Information' },
|
||||
];
|
||||
|
||||
// Default widget order per group (used when no preference is set)
|
||||
const DEFAULT_ORDER: Record<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() {
|
||||
const { user } = useAuth();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const queryClient = useQueryClient();
|
||||
const [dataLoading, setDataLoading] = useState(true);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
);
|
||||
|
||||
const { data: preferences } = useQuery({
|
||||
queryKey: ['user-preferences'],
|
||||
@@ -60,203 +110,214 @@ function Dashboard() {
|
||||
return preferences?.widgets?.[key] !== false;
|
||||
};
|
||||
|
||||
// Build widget definitions for each group
|
||||
const widgetDefs: Record<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(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDataLoading(false);
|
||||
}, 800);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const baseDelay = 300;
|
||||
let delayCounter = 0;
|
||||
const nextDelay = () => `${baseDelay + (delayCounter++) * 40}ms`;
|
||||
|
||||
// Render a group's widgets
|
||||
const renderGroup = (group: GroupName) => {
|
||||
const visible = getVisibleWidgets(group);
|
||||
const keys = visible.map((d) => d.key);
|
||||
|
||||
// Special handling for information group (links are dynamic)
|
||||
if (group === 'information') {
|
||||
const linksVisible = visible.some((d) => d.key === 'links');
|
||||
const bannerVisible = visible.some((d) => d.key === 'bannerWidget');
|
||||
const hasContent = (linksVisible && linkCollections.length > 0) || bannerVisible;
|
||||
if (!hasContent) return null;
|
||||
|
||||
return (
|
||||
<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 (
|
||||
<DashboardLayout>
|
||||
{/* Vikunja — Overdue Notifier (invisible, polling component — outside grid) */}
|
||||
<VikunjaOverdueNotifier />
|
||||
{/* Atemschutz — Expiry Notifier (invisible, polling component — outside grid) */}
|
||||
<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>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
gap: 2.5,
|
||||
alignItems: 'start',
|
||||
}}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{/* Announcement Banner — spans full width, renders null when no banners */}
|
||||
<AnnouncementBanner gridColumn="1 / -1" />
|
||||
<Box
|
||||
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 && (
|
||||
<Box sx={{ gridColumn: '1 / -1' }}>
|
||||
{dataLoading ? (
|
||||
<SkeletonCard variant="detailed" />
|
||||
) : (
|
||||
<Fade in={true} timeout={600} style={{ transitionDelay: '100ms' }}>
|
||||
<Box>
|
||||
<UserProfile user={user} />
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
</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>
|
||||
{user && (
|
||||
<Box sx={{ gridColumn: '1 / -1' }}>
|
||||
{dataLoading ? (
|
||||
<SkeletonCard variant="detailed" />
|
||||
) : (
|
||||
<Fade in={true} timeout={600} style={{ transitionDelay: '100ms' }}>
|
||||
<Box>
|
||||
<UserProfile user={user} />
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{hasPermission('ausruestung:widget') && widgetVisible('equipment') && (
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '350ms' }}>
|
||||
<Box>
|
||||
<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>
|
||||
{GROUP_ORDER.map((g) => renderGroup(g.name))}
|
||||
</Box>
|
||||
</DndContext>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
|
||||
@@ -77,7 +77,9 @@ import {
|
||||
import type { AusruestungListItem } from '../types/equipment.types';
|
||||
import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import FahrzeugChecklistTab from '../components/fahrzeuge/FahrzeugChecklistTab';
|
||||
|
||||
// ── Tab Panel ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -880,6 +882,7 @@ function FahrzeugDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { isAdmin, canChangeStatus, canManageMaintenance } = usePermissions();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const notification = useNotification();
|
||||
|
||||
const [vehicle, setVehicle] = useState<FahrzeugDetailType | null>(null);
|
||||
@@ -1035,6 +1038,7 @@ function FahrzeugDetail() {
|
||||
/>
|
||||
<Tab label="Einsätze" />
|
||||
<Tab label={`Ausrüstung${vehicleEquipment.length > 0 ? ` (${vehicleEquipment.length})` : ''}`} />
|
||||
{hasPermission('checklisten:view') && <Tab label="Checklisten" />}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
@@ -1071,6 +1075,12 @@ function FahrzeugDetail() {
|
||||
<AusruestungTab equipment={vehicleEquipment} vehicleId={vehicle.id} />
|
||||
</TabPanel>
|
||||
|
||||
{hasPermission('checklisten:view') && (
|
||||
<TabPanel value={activeTab} index={4}>
|
||||
<FahrzeugChecklistTab fahrzeugId={vehicle.id} />
|
||||
</TabPanel>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={deleteDialogOpen} onClose={() => !deleteLoading && setDeleteDialogOpen(false)}>
|
||||
<DialogTitle>Fahrzeug löschen</DialogTitle>
|
||||
|
||||
@@ -3,11 +3,14 @@ import {
|
||||
Box, Typography, Paper, Chip, IconButton, Button, Dialog, DialogTitle,
|
||||
DialogContent, DialogActions, TextField, MenuItem, Select, FormControl,
|
||||
InputLabel, Divider, CircularProgress, Autocomplete, Grid, Card, CardContent,
|
||||
List, ListItem, ListItemIcon, ListItemText, ListItemSecondaryAction,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack, Delete as DeleteIcon,
|
||||
BugReport, FiberNew, HelpOutline, Send as SendIcon,
|
||||
Circle as CircleIcon, Refresh as RefreshIcon, History,
|
||||
AttachFile as AttachFileIcon, InsertDriveFile as FileIcon,
|
||||
Upload as UploadIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
@@ -16,13 +19,29 @@ import { useNotification } from '../contexts/NotificationContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { issuesApi } from '../services/issues';
|
||||
import type { Issue, IssueComment, UpdateIssuePayload, AssignableMember, IssueStatusDef, IssuePriorityDef, IssueHistorie } from '../types/issue.types';
|
||||
import type { Issue, IssueComment, UpdateIssuePayload, AssignableMember, IssueStatusDef, IssuePriorityDef, IssueHistorie, IssueDatei } from '../types/issue.types';
|
||||
|
||||
// ── Helpers (copied from Issues.tsx) ──
|
||||
|
||||
const formatDate = (iso?: string) =>
|
||||
iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-';
|
||||
|
||||
const formatDateOnly = (iso?: string | null) =>
|
||||
iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '-';
|
||||
|
||||
const toInputDate = (iso?: string | null) => {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number | null) => {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const formatIssueId = (issue: Issue) =>
|
||||
`${new Date(issue.created_at).getFullYear()}/${issue.id}`;
|
||||
|
||||
@@ -123,6 +142,12 @@ export default function IssueDetail() {
|
||||
enabled: !isNaN(issueId),
|
||||
});
|
||||
|
||||
const { data: files = [] } = useQuery<IssueDatei[]>({
|
||||
queryKey: ['issues', issueId, 'files'],
|
||||
queryFn: () => issuesApi.getFiles(issueId),
|
||||
enabled: !isNaN(issueId),
|
||||
});
|
||||
|
||||
// ── Permissions ──
|
||||
const isOwner = issue?.erstellt_von === userId;
|
||||
const isAssignee = issue?.zugewiesen_an === userId;
|
||||
@@ -175,6 +200,30 @@ export default function IssueDetail() {
|
||||
onError: () => showError('Kommentar konnte nicht erstellt werden'),
|
||||
});
|
||||
|
||||
const uploadFileMut = useMutation({
|
||||
mutationFn: (file: File) => issuesApi.uploadFile(issueId, file),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['issues', issueId, 'files'] });
|
||||
showSuccess('Datei hochgeladen');
|
||||
},
|
||||
onError: () => showError('Datei konnte nicht hochgeladen werden'),
|
||||
});
|
||||
|
||||
const deleteFileMut = useMutation({
|
||||
mutationFn: (fileId: string) => issuesApi.deleteFile(fileId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['issues', issueId, 'files'] });
|
||||
showSuccess('Datei gelöscht');
|
||||
},
|
||||
onError: () => showError('Datei konnte nicht gelöscht werden'),
|
||||
});
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) uploadFileMut.mutate(file);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleReopen = () => {
|
||||
updateMut.mutate({ status: initialStatusKey, kommentar: reopenComment.trim() }, {
|
||||
onSuccess: () => {
|
||||
@@ -275,6 +324,20 @@ export default function IssueDetail() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
|
||||
{/* Description */}
|
||||
@@ -285,6 +348,57 @@ export default function IssueDetail() {
|
||||
</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 */}
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{/* Status control */}
|
||||
@@ -341,6 +455,19 @@ export default function IssueDetail() {
|
||||
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>
|
||||
|
||||
{/* Delete button */}
|
||||
|
||||
@@ -121,6 +121,16 @@ export default function IssueNeu() {
|
||||
</Select>
|
||||
</FormControl>
|
||||
</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>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 1 }}>
|
||||
|
||||
@@ -4,13 +4,14 @@ import {
|
||||
TableHead, TableRow, Paper, Chip, IconButton, Button, Dialog, DialogTitle,
|
||||
DialogContent, DialogActions, TextField, MenuItem, Select, FormControl,
|
||||
InputLabel, CircularProgress, FormControlLabel, Switch,
|
||||
Autocomplete,
|
||||
Autocomplete, ToggleButtonGroup, ToggleButton,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon, Delete as DeleteIcon,
|
||||
BugReport, FiberNew, HelpOutline,
|
||||
Circle as CircleIcon, Edit as EditIcon,
|
||||
DragIndicator, Check as CheckIcon, Close as CloseIcon,
|
||||
ViewList as ViewListIcon, ViewKanban as ViewKanbanIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
@@ -21,6 +22,7 @@ import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { issuesApi } from '../services/issues';
|
||||
import type { Issue, IssueTyp, IssueFilters, AssignableMember, IssueStatusDef, IssuePriorityDef } from '../types/issue.types';
|
||||
import KanbanBoard from '../components/issues/KanbanBoard';
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
@@ -555,8 +557,10 @@ function IssueSettings() {
|
||||
export default function Issues() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const { user } = useAuth();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
|
||||
const canViewAll = hasPermission('issues:view_all');
|
||||
const hasEdit = hasPermission('issues:edit');
|
||||
@@ -581,6 +585,27 @@ export default function Issues() {
|
||||
const [showDoneMine, setShowDoneMine] = useState(false);
|
||||
const [showDoneAssigned, setShowDoneAssigned] = useState(false);
|
||||
const [filters, setFilters] = useState<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
|
||||
const { data: issues = [], isLoading } = useQuery({
|
||||
@@ -634,7 +659,22 @@ export default function Issues() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<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.map((t, i) => <Tab key={i} label={t.label} />)}
|
||||
@@ -642,13 +682,24 @@ export default function Issues() {
|
||||
|
||||
{/* Tab 0: Meine Issues */}
|
||||
<TabPanel value={tab} index={0}>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={showDoneMine} onChange={(e) => setShowDoneMine(e.target.checked)} size="small" />}
|
||||
label="Erledigte anzeigen"
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
||||
{viewMode === 'list' && (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={showDoneMine} onChange={(e) => setShowDoneMine(e.target.checked)} size="small" />}
|
||||
label="Erledigte anzeigen"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{isLoading ? (
|
||||
<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} />
|
||||
)}
|
||||
@@ -656,13 +707,24 @@ export default function Issues() {
|
||||
|
||||
{/* Tab 1: Zugewiesene Issues */}
|
||||
<TabPanel value={tab} index={1}>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={showDoneAssigned} onChange={(e) => setShowDoneAssigned(e.target.checked)} size="small" />}
|
||||
label="Erledigte anzeigen"
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
||||
{viewMode === 'list' && (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={showDoneAssigned} onChange={(e) => setShowDoneAssigned(e.target.checked)} size="small" />}
|
||||
label="Erledigte anzeigen"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{isLoading ? (
|
||||
<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} />
|
||||
)}
|
||||
@@ -674,6 +736,14 @@ export default function Issues() {
|
||||
<FilterBar filters={filters} onChange={setFilters} types={types} members={members} statuses={issueStatuses} priorities={issuePriorities} />
|
||||
{isFilteredLoading ? (
|
||||
<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} />
|
||||
)}
|
||||
|
||||
131
frontend/src/services/checklisten.ts
Normal file
131
frontend/src/services/checklisten.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
28
frontend/src/services/fahrzeugTypen.ts
Normal file
28
frontend/src/services/fahrzeugTypen.ts
Normal 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}`);
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api } from './api';
|
||||
import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload, IssueTyp, IssueFilters, AssignableMember, IssueStatusDef, IssuePriorityDef, IssueWidgetSummary, IssueHistorie } from '../types/issue.types';
|
||||
import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload, IssueTyp, IssueFilters, AssignableMember, IssueStatusDef, IssuePriorityDef, IssueWidgetSummary, IssueHistorie, IssueDatei } from '../types/issue.types';
|
||||
|
||||
export const issuesApi = {
|
||||
getIssues: async (filters?: IssueFilters): Promise<Issue[]> => {
|
||||
@@ -98,4 +98,20 @@ export const issuesApi = {
|
||||
deletePriority: async (id: number): Promise<void> => {
|
||||
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}`);
|
||||
},
|
||||
};
|
||||
|
||||
162
frontend/src/types/checklist.types.ts
Normal file
162
frontend/src/types/checklist.types.ts
Normal 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[];
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export interface Issue {
|
||||
erstellt_von_name?: string;
|
||||
zugewiesen_an: string | null;
|
||||
zugewiesen_an_name?: string | null;
|
||||
faellig_am: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -53,6 +54,7 @@ export interface CreateIssuePayload {
|
||||
beschreibung?: string;
|
||||
typ_id?: number;
|
||||
prioritaet?: string;
|
||||
faellig_am?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateIssuePayload {
|
||||
@@ -63,6 +65,7 @@ export interface UpdateIssuePayload {
|
||||
status?: string;
|
||||
zugewiesen_an?: string | null;
|
||||
kommentar?: string;
|
||||
faellig_am?: string | null;
|
||||
}
|
||||
|
||||
export interface IssueFilters {
|
||||
@@ -108,3 +111,14 @@ export interface IssueStatusCount {
|
||||
}
|
||||
|
||||
export type IssueWidgetSummary = IssueStatusCount[];
|
||||
|
||||
export interface IssueDatei {
|
||||
id: string;
|
||||
issue_id: number;
|
||||
dateiname: string;
|
||||
dateipfad: string;
|
||||
dateityp: string | null;
|
||||
dateigroesse: number | null;
|
||||
hochgeladen_von: string | null;
|
||||
hochgeladen_am: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user