feat: vehicle/equipment type system, equipment checklist support, and checklist overview redesign
This commit is contained in:
@@ -106,6 +106,7 @@ import issueRoutes from './routes/issue.routes';
|
|||||||
import buchungskategorieRoutes from './routes/buchungskategorie.routes';
|
import buchungskategorieRoutes from './routes/buchungskategorie.routes';
|
||||||
import checklistRoutes from './routes/checklist.routes';
|
import checklistRoutes from './routes/checklist.routes';
|
||||||
import fahrzeugTypRoutes from './routes/fahrzeugTyp.routes';
|
import fahrzeugTypRoutes from './routes/fahrzeugTyp.routes';
|
||||||
|
import ausruestungTypRoutes from './routes/ausruestungTyp.routes';
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/user', userRoutes);
|
app.use('/api/user', userRoutes);
|
||||||
@@ -134,6 +135,7 @@ app.use('/api/issues', issueRoutes);
|
|||||||
app.use('/api/buchungskategorien', buchungskategorieRoutes);
|
app.use('/api/buchungskategorien', buchungskategorieRoutes);
|
||||||
app.use('/api/checklisten', checklistRoutes);
|
app.use('/api/checklisten', checklistRoutes);
|
||||||
app.use('/api/fahrzeug-typen', fahrzeugTypRoutes);
|
app.use('/api/fahrzeug-typen', fahrzeugTypRoutes);
|
||||||
|
app.use('/api/ausruestung-typen', ausruestungTypRoutes);
|
||||||
|
|
||||||
// Static file serving for uploads (authenticated)
|
// Static file serving for uploads (authenticated)
|
||||||
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');
|
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');
|
||||||
|
|||||||
130
backend/src/controllers/ausruestungTyp.controller.ts
Normal file
130
backend/src/controllers/ausruestungTyp.controller.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import ausruestungTypService from '../services/ausruestungTyp.service';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
const param = (req: Request, key: string): string => req.params[key] as string;
|
||||||
|
|
||||||
|
class AusruestungTypController {
|
||||||
|
async getAll(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const types = await ausruestungTypService.getAll();
|
||||||
|
res.status(200).json({ success: true, data: types });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AusruestungTypController.getAll error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Ausrüstungs-Typen konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(req: Request, res: Response): Promise<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 ausruestungTypService.getById(id);
|
||||||
|
if (!type) {
|
||||||
|
res.status(404).json({ success: false, message: 'Ausrüstungs-Typ nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true, data: type });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AusruestungTypController.getById error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Ausrüstungs-Typ konnte nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(req: Request, res: Response): Promise<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 ausruestungTypService.create(req.body);
|
||||||
|
res.status(201).json({ success: true, data: type });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AusruestungTypController.create error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Ausrüstungs-Typ konnte nicht erstellt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(req: Request, res: Response): Promise<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 ausruestungTypService.update(id, req.body);
|
||||||
|
if (!type) {
|
||||||
|
res.status(404).json({ success: false, message: 'Ausrüstungs-Typ nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true, data: type });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AusruestungTypController.update error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Ausrüstungs-Typ konnte nicht aktualisiert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTyp(req: Request, res: Response): Promise<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 ausruestungTypService.delete(id);
|
||||||
|
if (!type) {
|
||||||
|
res.status(404).json({ success: false, message: 'Ausrüstungs-Typ nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true, message: 'Ausrüstungs-Typ gelöscht' });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'Typ wird noch von Ausrüstung verwendet') {
|
||||||
|
res.status(409).json({ success: false, message: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('AusruestungTypController.deleteTyp error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Ausrüstungs-Typ konnte nicht gelöscht werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTypesForEquipment(req: Request, res: Response): Promise<void> {
|
||||||
|
const ausruestungId = param(req, 'ausruestungId');
|
||||||
|
if (!ausruestungId) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ausrüstungs-ID erforderlich' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const types = await ausruestungTypService.getTypesForEquipment(ausruestungId);
|
||||||
|
res.status(200).json({ success: true, data: types });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AusruestungTypController.getTypesForEquipment error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Ausrüstungs-Typen konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setTypesForEquipment(req: Request, res: Response): Promise<void> {
|
||||||
|
const ausruestungId = param(req, 'ausruestungId');
|
||||||
|
if (!ausruestungId) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ausrüstungs-ID erforderlich' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { typIds } = req.body;
|
||||||
|
if (!Array.isArray(typIds)) {
|
||||||
|
res.status(400).json({ success: false, message: 'typIds muss ein Array sein' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const types = await ausruestungTypService.setTypesForEquipment(ausruestungId, typIds);
|
||||||
|
res.status(200).json({ success: true, data: types });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AusruestungTypController.setTypesForEquipment error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Ausrüstungs-Typen konnten nicht gesetzt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new AusruestungTypController();
|
||||||
@@ -5,14 +5,29 @@ import logger from '../utils/logger';
|
|||||||
const param = (req: Request, key: string): string => req.params[key] as string;
|
const param = (req: Request, key: string): string => req.params[key] as string;
|
||||||
|
|
||||||
class ChecklistController {
|
class ChecklistController {
|
||||||
|
// --- Overview ---
|
||||||
|
|
||||||
|
async getOverviewItems(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await checklistService.getOverviewItems();
|
||||||
|
res.status(200).json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ChecklistController.getOverviewItems error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Übersichtsdaten konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Vorlagen (Templates) ---
|
// --- Vorlagen (Templates) ---
|
||||||
|
|
||||||
async getVorlagen(req: Request, res: Response): Promise<void> {
|
async getVorlagen(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const filter: { fahrzeug_typ_id?: number; aktiv?: boolean } = {};
|
const filter: { fahrzeug_typ_id?: number; ausruestung_typ_id?: number; aktiv?: boolean } = {};
|
||||||
if (req.query.fahrzeug_typ_id) {
|
if (req.query.fahrzeug_typ_id) {
|
||||||
filter.fahrzeug_typ_id = parseInt(req.query.fahrzeug_typ_id as string, 10);
|
filter.fahrzeug_typ_id = parseInt(req.query.fahrzeug_typ_id as string, 10);
|
||||||
}
|
}
|
||||||
|
if (req.query.ausruestung_typ_id) {
|
||||||
|
filter.ausruestung_typ_id = parseInt(req.query.ausruestung_typ_id as string, 10);
|
||||||
|
}
|
||||||
if (req.query.aktiv !== undefined) {
|
if (req.query.aktiv !== undefined) {
|
||||||
filter.aktiv = req.query.aktiv === 'true';
|
filter.aktiv = req.query.aktiv === 'true';
|
||||||
}
|
}
|
||||||
@@ -263,16 +278,108 @@ class ChecklistController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Ausführungen (Executions) ---
|
// --- Templates for equipment ---
|
||||||
|
|
||||||
async startExecution(req: Request, res: Response): Promise<void> {
|
async getTemplatesForEquipment(req: Request, res: Response): Promise<void> {
|
||||||
const { fahrzeugId, vorlageId } = req.body;
|
const ausruestungId = param(req, 'ausruestungId');
|
||||||
if (!fahrzeugId || !vorlageId) {
|
if (!ausruestungId) {
|
||||||
res.status(400).json({ success: false, message: 'fahrzeugId und vorlageId sind erforderlich' });
|
res.status(400).json({ success: false, message: 'Ausrüstungs-ID erforderlich' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const execution = await checklistService.startExecution(fahrzeugId, vorlageId, req.user!.id);
|
const templates = await checklistService.getTemplatesForEquipment(ausruestungId);
|
||||||
|
res.status(200).json({ success: true, data: templates });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ChecklistController.getTemplatesForEquipment error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Checklisten konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Equipment-specific items ---
|
||||||
|
|
||||||
|
async getEquipmentItems(req: Request, res: Response): Promise<void> {
|
||||||
|
const ausruestungId = param(req, 'ausruestungId');
|
||||||
|
if (!ausruestungId) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ausrüstungs-ID erforderlich' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const items = await checklistService.getEquipmentItems(ausruestungId);
|
||||||
|
res.status(200).json({ success: true, data: items });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ChecklistController.getEquipmentItems error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Items konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addEquipmentItem(req: Request, res: Response): Promise<void> {
|
||||||
|
const ausruestungId = param(req, 'ausruestungId');
|
||||||
|
if (!ausruestungId) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ausrüstungs-ID erforderlich' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { bezeichnung } = req.body;
|
||||||
|
if (!bezeichnung || typeof bezeichnung !== 'string' || bezeichnung.trim().length === 0) {
|
||||||
|
res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const item = await checklistService.addEquipmentItem(ausruestungId, req.body);
|
||||||
|
res.status(201).json({ success: true, data: item });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ChecklistController.addEquipmentItem error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Item konnte nicht erstellt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateEquipmentItem(req: Request, res: Response): Promise<void> {
|
||||||
|
const id = parseInt(param(req, 'itemId'), 10);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const item = await checklistService.updateEquipmentItem(id, req.body);
|
||||||
|
if (!item) {
|
||||||
|
res.status(404).json({ success: false, message: 'Item nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true, data: item });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ChecklistController.updateEquipmentItem error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Item konnte nicht aktualisiert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEquipmentItem(req: Request, res: Response): Promise<void> {
|
||||||
|
const id = parseInt(param(req, 'itemId'), 10);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const item = await checklistService.deleteEquipmentItem(id);
|
||||||
|
if (!item) {
|
||||||
|
res.status(404).json({ success: false, message: 'Item nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true, message: 'Item deaktiviert' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ChecklistController.deleteEquipmentItem error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Item konnte nicht deaktiviert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ausführungen (Executions) ---
|
||||||
|
|
||||||
|
async startExecution(req: Request, res: Response): Promise<void> {
|
||||||
|
const { fahrzeugId, ausruestungId, vorlageId } = req.body;
|
||||||
|
if (!vorlageId || (!fahrzeugId && !ausruestungId)) {
|
||||||
|
res.status(400).json({ success: false, message: 'vorlageId und entweder fahrzeugId oder ausruestungId sind erforderlich' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const execution = await checklistService.startExecution(fahrzeugId || null, vorlageId, req.user!.id, ausruestungId || null);
|
||||||
res.status(201).json({ success: true, data: execution });
|
res.status(201).json({ success: true, data: execution });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('ChecklistController.startExecution error', { error });
|
logger.error('ChecklistController.startExecution error', { error });
|
||||||
@@ -321,8 +428,9 @@ class ChecklistController {
|
|||||||
|
|
||||||
async getExecutions(req: Request, res: Response): Promise<void> {
|
async getExecutions(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const filter: { fahrzeugId?: string; vorlageId?: number; status?: string } = {};
|
const filter: { fahrzeugId?: string; ausruestungId?: string; vorlageId?: number; status?: string } = {};
|
||||||
if (req.query.fahrzeugId) filter.fahrzeugId = req.query.fahrzeugId as string;
|
if (req.query.fahrzeugId) filter.fahrzeugId = req.query.fahrzeugId as string;
|
||||||
|
if (req.query.ausruestungId) filter.ausruestungId = req.query.ausruestungId as string;
|
||||||
if (req.query.vorlageId) filter.vorlageId = parseInt(req.query.vorlageId as string, 10);
|
if (req.query.vorlageId) filter.vorlageId = parseInt(req.query.vorlageId as string, 10);
|
||||||
if (req.query.status) filter.status = req.query.status as string;
|
if (req.query.status) filter.status = req.query.status as string;
|
||||||
const executions = await checklistService.getExecutions(filter);
|
const executions = await checklistService.getExecutions(filter);
|
||||||
|
|||||||
62
backend/src/database/migrations/070_ausruestung_typen.sql
Normal file
62
backend/src/database/migrations/070_ausruestung_typen.sql
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
-- Migration 070: Ausruestung-Typen (Equipment Types)
|
||||||
|
-- Dynamic equipment type table with many-to-many junction to ausruestung.
|
||||||
|
-- Mirrors the fahrzeug_typen pattern from migration 067.
|
||||||
|
-- Seeds initial types from existing ausruestung_kategorien values.
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. Ausruestung-Typen
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ausruestung_typen (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
beschreibung TEXT,
|
||||||
|
icon VARCHAR(50),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. Junction table (many-to-many)
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ausruestung_ausruestung_typen (
|
||||||
|
ausruestung_id UUID NOT NULL REFERENCES ausruestung(id) ON DELETE CASCADE,
|
||||||
|
ausruestung_typ_id INT NOT NULL REFERENCES ausruestung_typen(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (ausruestung_id, ausruestung_typ_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_aat_ausruestung_id ON ausruestung_ausruestung_typen(ausruestung_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_aat_typ_id ON ausruestung_ausruestung_typen(ausruestung_typ_id);
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. Seed types from existing ausruestung_kategorien
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
INSERT INTO ausruestung_typen (name, beschreibung)
|
||||||
|
SELECT name, kurzname
|
||||||
|
FROM ausruestung_kategorien
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- Populate junction table from existing kategorie_id assignments
|
||||||
|
INSERT INTO ausruestung_ausruestung_typen (ausruestung_id, ausruestung_typ_id)
|
||||||
|
SELECT a.id, at2.id
|
||||||
|
FROM ausruestung a
|
||||||
|
JOIN ausruestung_kategorien ak ON a.kategorie_id = ak.id
|
||||||
|
JOIN ausruestung_typen at2 ON at2.name = ak.name
|
||||||
|
WHERE a.kategorie_id IS NOT NULL
|
||||||
|
AND a.deleted_at IS NULL
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 4. Permission for managing equipment types
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
|
||||||
|
('ausruestung:manage_types', 'ausruestung', 'Typen verwalten', 'Ausruestung-Typen erstellen und bearbeiten', 10)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Grant to kommando and zeugmeister
|
||||||
|
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
|
||||||
|
('dashboard_kommando', 'ausruestung:manage_types'),
|
||||||
|
('dashboard_zeugmeister', 'ausruestung:manage_types')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
-- Migration 071: Checklisten Equipment Extensions
|
||||||
|
-- Extends the checklist system to support equipment (Ausruestung) alongside vehicles.
|
||||||
|
-- Depends on: 068_checklisten.sql, 070_ausruestung_typen.sql
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. Extend checklist_vorlagen with direct assignment columns
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER TABLE checklist_vorlagen
|
||||||
|
ADD COLUMN IF NOT EXISTS fahrzeug_id UUID REFERENCES fahrzeuge(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE checklist_vorlagen
|
||||||
|
ADD COLUMN IF NOT EXISTS ausruestung_id UUID REFERENCES ausruestung(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE checklist_vorlagen
|
||||||
|
ADD COLUMN IF NOT EXISTS ausruestung_typ_id INT REFERENCES ausruestung_typen(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cv_fahrzeug_id ON checklist_vorlagen(fahrzeug_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cv_ausruestung_id ON checklist_vorlagen(ausruestung_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cv_ausruestung_typ_id ON checklist_vorlagen(ausruestung_typ_id);
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. Extend checklist_ausfuehrungen with ausruestung support
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- Make fahrzeug_id nullable (was NOT NULL; now either fahrzeug or ausruestung)
|
||||||
|
ALTER TABLE checklist_ausfuehrungen
|
||||||
|
ALTER COLUMN fahrzeug_id DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE checklist_ausfuehrungen
|
||||||
|
ADD COLUMN IF NOT EXISTS ausruestung_id UUID REFERENCES ausruestung(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ca_ausruestung_id ON checklist_ausfuehrungen(ausruestung_id);
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. Extend checklist_faelligkeit with ausruestung support
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- Make fahrzeug_id nullable (was part of composite PK)
|
||||||
|
ALTER TABLE checklist_faelligkeit
|
||||||
|
DROP CONSTRAINT IF EXISTS checklist_faelligkeit_pkey;
|
||||||
|
|
||||||
|
ALTER TABLE checklist_faelligkeit
|
||||||
|
ALTER COLUMN fahrzeug_id DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE checklist_faelligkeit
|
||||||
|
ADD COLUMN IF NOT EXISTS ausruestung_id UUID REFERENCES ausruestung(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Use partial unique indexes instead of UNIQUE NULLS NOT DISTINCT (compatible with PG < 15)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_cf_fahrzeug_vorlage
|
||||||
|
ON checklist_faelligkeit (vorlage_id, fahrzeug_id)
|
||||||
|
WHERE fahrzeug_id IS NOT NULL AND ausruestung_id IS NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_cf_ausruestung_vorlage
|
||||||
|
ON checklist_faelligkeit (vorlage_id, ausruestung_id)
|
||||||
|
WHERE ausruestung_id IS NOT NULL AND fahrzeug_id IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cf_ausruestung_id ON checklist_faelligkeit(ausruestung_id);
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 4. Ausruestung-spezifische Checklist Items
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ausruestung_checklist_items (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
ausruestung_id UUID NOT NULL REFERENCES ausruestung(id) ON DELETE CASCADE,
|
||||||
|
bezeichnung VARCHAR(500) NOT NULL,
|
||||||
|
beschreibung TEXT,
|
||||||
|
pflicht BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
aktiv BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_aci_ausruestung_id ON ausruestung_checklist_items(ausruestung_id);
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 5. Extend checklist_ausfuehrung_items to reference ausruestung items
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER TABLE checklist_ausfuehrung_items
|
||||||
|
ADD COLUMN IF NOT EXISTS ausruestung_item_id INT REFERENCES ausruestung_checklist_items(id) ON DELETE SET NULL;
|
||||||
@@ -13,15 +13,19 @@ async function runChecklistReminderCheck(): Promise<void> {
|
|||||||
}
|
}
|
||||||
isRunning = true;
|
isRunning = true;
|
||||||
try {
|
try {
|
||||||
// Find overdue checklists
|
// Find overdue checklists (vehicles + equipment)
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
SELECT cf.fahrzeug_id, cf.vorlage_id, cf.naechste_faellig_am,
|
SELECT cf.fahrzeug_id, cf.ausruestung_id, cf.vorlage_id, cf.naechste_faellig_am,
|
||||||
f.bezeichnung AS fahrzeug_name,
|
f.bezeichnung AS fahrzeug_name,
|
||||||
|
ar.name AS ausruestung_name,
|
||||||
v.name AS vorlage_name
|
v.name AS vorlage_name
|
||||||
FROM checklist_faelligkeit cf
|
FROM checklist_faelligkeit cf
|
||||||
JOIN fahrzeuge f ON f.id = cf.fahrzeug_id AND f.deleted_at IS NULL
|
LEFT JOIN fahrzeuge f ON f.id = cf.fahrzeug_id AND f.deleted_at IS NULL
|
||||||
|
LEFT JOIN ausruestung ar ON ar.id = cf.ausruestung_id
|
||||||
JOIN checklist_vorlagen v ON v.id = cf.vorlage_id AND v.aktiv = true
|
JOIN checklist_vorlagen v ON v.id = cf.vorlage_id AND v.aktiv = true
|
||||||
WHERE cf.naechste_faellig_am <= CURRENT_DATE
|
WHERE cf.naechste_faellig_am <= CURRENT_DATE
|
||||||
|
AND (cf.fahrzeug_id IS NOT NULL OR cf.ausruestung_id IS NOT NULL)
|
||||||
|
AND (cf.fahrzeug_id IS NULL OR f.id IS NOT NULL)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
if (result.rows.length === 0) return;
|
if (result.rows.length === 0) return;
|
||||||
@@ -40,16 +44,21 @@ async function runChecklistReminderCheck(): Promise<void> {
|
|||||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify first responsible user (avoid spam by using quell_id dedup)
|
const targetName = row.fahrzeug_name || row.ausruestung_name || 'Unbekannt';
|
||||||
|
const quellId = row.fahrzeug_id
|
||||||
|
? `${row.fahrzeug_id}_${row.vorlage_id}`
|
||||||
|
: `eq_${row.ausruestung_id}_${row.vorlage_id}`;
|
||||||
|
|
||||||
|
// Notify responsible users (dedup handled by quell_id)
|
||||||
for (const userId of targetUserIds) {
|
for (const userId of targetUserIds) {
|
||||||
await notificationService.createNotification({
|
await notificationService.createNotification({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
typ: 'checklist_faellig',
|
typ: 'checklist_faellig',
|
||||||
titel: `Checkliste überfällig: ${row.vorlage_name}`,
|
titel: `Checkliste überfällig: ${row.vorlage_name}`,
|
||||||
nachricht: `Die Checkliste "${row.vorlage_name}" für ${row.fahrzeug_name} war fällig am ${faelligDatum}`,
|
nachricht: `Die Checkliste "${row.vorlage_name}" für ${targetName} war fällig am ${faelligDatum}`,
|
||||||
schwere: 'warnung',
|
schwere: 'warnung',
|
||||||
link: `/checklisten`,
|
link: `/checklisten`,
|
||||||
quell_id: `${row.fahrzeug_id}_${row.vorlage_id}`,
|
quell_id: quellId,
|
||||||
quell_typ: 'checklist_faellig',
|
quell_typ: 'checklist_faellig',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
58
backend/src/routes/ausruestungTyp.routes.ts
Normal file
58
backend/src/routes/ausruestungTyp.routes.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import ausruestungTypController from '../controllers/ausruestungTyp.controller';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import { requirePermission } from '../middleware/rbac.middleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// List all equipment types
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
authenticate,
|
||||||
|
ausruestungTypController.getAll.bind(ausruestungTypController)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Equipment-specific type management (BEFORE /:id to avoid route conflicts)
|
||||||
|
router.get(
|
||||||
|
'/equipment/:ausruestungId',
|
||||||
|
authenticate,
|
||||||
|
ausruestungTypController.getTypesForEquipment.bind(ausruestungTypController)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/equipment/:ausruestungId',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('ausruestung:manage_types'),
|
||||||
|
ausruestungTypController.setTypesForEquipment.bind(ausruestungTypController)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get single equipment type
|
||||||
|
router.get(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
ausruestungTypController.getById.bind(ausruestungTypController)
|
||||||
|
);
|
||||||
|
|
||||||
|
// CRUD — permission-protected
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('ausruestung:manage_types'),
|
||||||
|
ausruestungTypController.create.bind(ausruestungTypController)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('ausruestung:manage_types'),
|
||||||
|
ausruestungTypController.update.bind(ausruestungTypController)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('ausruestung:manage_types'),
|
||||||
|
ausruestungTypController.deleteTyp.bind(ausruestungTypController)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -13,6 +13,14 @@ router.get(
|
|||||||
checklistController.getOverdueChecklists.bind(checklistController)
|
checklistController.getOverdueChecklists.bind(checklistController)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Overview ---
|
||||||
|
router.get(
|
||||||
|
'/overview',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('checklisten:view'),
|
||||||
|
checklistController.getOverviewItems.bind(checklistController)
|
||||||
|
);
|
||||||
|
|
||||||
// --- Vorlagen (Templates) ---
|
// --- Vorlagen (Templates) ---
|
||||||
router.get(
|
router.get(
|
||||||
'/vorlagen',
|
'/vorlagen',
|
||||||
@@ -124,6 +132,42 @@ router.get(
|
|||||||
checklistController.getDueChecklists.bind(checklistController)
|
checklistController.getDueChecklists.bind(checklistController)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Equipment-specific items ---
|
||||||
|
router.get(
|
||||||
|
'/equipment/:ausruestungId/vorlagen',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('checklisten:view'),
|
||||||
|
checklistController.getTemplatesForEquipment.bind(checklistController)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/equipment/:ausruestungId/items',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('checklisten:view'),
|
||||||
|
checklistController.getEquipmentItems.bind(checklistController)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/equipment/:ausruestungId/items',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('checklisten:manage_templates'),
|
||||||
|
checklistController.addEquipmentItem.bind(checklistController)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
'/equipment/:ausruestungId/items/:itemId',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('checklisten:manage_templates'),
|
||||||
|
checklistController.updateEquipmentItem.bind(checklistController)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/equipment/:ausruestungId/items/:itemId',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('checklisten:manage_templates'),
|
||||||
|
checklistController.deleteEquipmentItem.bind(checklistController)
|
||||||
|
);
|
||||||
|
|
||||||
// --- Ausführungen (Executions) ---
|
// --- Ausführungen (Executions) ---
|
||||||
router.get(
|
router.get(
|
||||||
'/ausfuehrungen',
|
'/ausfuehrungen',
|
||||||
|
|||||||
151
backend/src/services/ausruestungTyp.service.ts
Normal file
151
backend/src/services/ausruestungTyp.service.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import pool from '../config/database';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
class AusruestungTypService {
|
||||||
|
async getAll() {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM ausruestung_typen ORDER BY name ASC`
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AusruestungTypService.getAll failed', { error });
|
||||||
|
throw new Error('Ausrüstungs-Typen konnten nicht geladen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id: number) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM ausruestung_typen WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AusruestungTypService.getById failed', { error, id });
|
||||||
|
throw new Error('Ausrüstungs-Typ konnte nicht geladen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: { name: string; beschreibung?: string; icon?: string }) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO ausruestung_typen (name, beschreibung, icon)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING *`,
|
||||||
|
[data.name, data.beschreibung ?? null, data.icon ?? null]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AusruestungTypService.create failed', { error });
|
||||||
|
throw new Error('Ausrüstungs-Typ konnte nicht erstellt werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: number, data: { name?: string; beschreibung?: string; icon?: string }) {
|
||||||
|
try {
|
||||||
|
const setClauses: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (data.name !== undefined) {
|
||||||
|
setClauses.push(`name = $${idx}`);
|
||||||
|
values.push(data.name);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
if (data.beschreibung !== undefined) {
|
||||||
|
setClauses.push(`beschreibung = $${idx}`);
|
||||||
|
values.push(data.beschreibung);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
if (data.icon !== undefined) {
|
||||||
|
setClauses.push(`icon = $${idx}`);
|
||||||
|
values.push(data.icon);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setClauses.length === 0) {
|
||||||
|
return this.getById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id);
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE ausruestung_typen SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AusruestungTypService.update failed', { error, id });
|
||||||
|
throw new Error('Ausrüstungs-Typ konnte nicht aktualisiert werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: number) {
|
||||||
|
try {
|
||||||
|
// Check if any equipment uses this type
|
||||||
|
const usage = await pool.query(
|
||||||
|
`SELECT COUNT(*) FROM ausruestung_ausruestung_typen WHERE ausruestung_typ_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (parseInt(usage.rows[0].count, 10) > 0) {
|
||||||
|
throw new Error('Typ wird noch von Ausrüstung verwendet');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`DELETE FROM ausruestung_typen WHERE id = $1 RETURNING *`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AusruestungTypService.delete failed', { error, id });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTypesForEquipment(ausruestungId: string) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT at.* FROM ausruestung_typen at
|
||||||
|
JOIN ausruestung_ausruestung_typen aat ON aat.ausruestung_typ_id = at.id
|
||||||
|
WHERE aat.ausruestung_id = $1
|
||||||
|
ORDER BY at.name ASC`,
|
||||||
|
[ausruestungId]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AusruestungTypService.getTypesForEquipment failed', { error, ausruestungId });
|
||||||
|
throw new Error('Ausrüstungs-Typen konnten nicht geladen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setTypesForEquipment(ausruestungId: string, typIds: number[]) {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM ausruestung_ausruestung_typen WHERE ausruestung_id = $1`,
|
||||||
|
[ausruestungId]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const typId of typIds) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO ausruestung_ausruestung_typen (ausruestung_id, ausruestung_typ_id) VALUES ($1, $2)`,
|
||||||
|
[ausruestungId, typId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return this.getTypesForEquipment(ausruestungId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK').catch(() => {});
|
||||||
|
logger.error('AusruestungTypService.setTypesForEquipment failed', { error, ausruestungId });
|
||||||
|
throw new Error('Ausrüstungs-Typen konnten nicht gesetzt werden');
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new AusruestungTypService();
|
||||||
@@ -30,7 +30,7 @@ function calculateNextDueDate(intervall: string | null, intervall_tage: number |
|
|||||||
// Vorlagen (Templates)
|
// Vorlagen (Templates)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function getVorlagen(filter?: { fahrzeug_typ_id?: number; aktiv?: boolean }) {
|
async function getVorlagen(filter?: { fahrzeug_typ_id?: number; ausruestung_typ_id?: number; aktiv?: boolean }) {
|
||||||
try {
|
try {
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
const values: any[] = [];
|
const values: any[] = [];
|
||||||
@@ -41,6 +41,11 @@ async function getVorlagen(filter?: { fahrzeug_typ_id?: number; aktiv?: boolean
|
|||||||
values.push(filter.fahrzeug_typ_id);
|
values.push(filter.fahrzeug_typ_id);
|
||||||
idx++;
|
idx++;
|
||||||
}
|
}
|
||||||
|
if (filter?.ausruestung_typ_id !== undefined) {
|
||||||
|
conditions.push(`v.ausruestung_typ_id = $${idx}`);
|
||||||
|
values.push(filter.ausruestung_typ_id);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
if (filter?.aktiv !== undefined) {
|
if (filter?.aktiv !== undefined) {
|
||||||
conditions.push(`v.aktiv = $${idx}`);
|
conditions.push(`v.aktiv = $${idx}`);
|
||||||
values.push(filter.aktiv);
|
values.push(filter.aktiv);
|
||||||
@@ -49,9 +54,12 @@ async function getVorlagen(filter?: { fahrzeug_typ_id?: number; aktiv?: boolean
|
|||||||
|
|
||||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT v.*, ft.name AS fahrzeug_typ_name
|
`SELECT v.*,
|
||||||
|
ft.name AS fahrzeug_typ_name,
|
||||||
|
at.name AS ausruestung_typ_name
|
||||||
FROM checklist_vorlagen v
|
FROM checklist_vorlagen v
|
||||||
LEFT JOIN fahrzeug_typen ft ON ft.id = v.fahrzeug_typ_id
|
LEFT JOIN fahrzeug_typen ft ON ft.id = v.fahrzeug_typ_id
|
||||||
|
LEFT JOIN ausruestung_typen at ON at.id = v.ausruestung_typ_id
|
||||||
${where}
|
${where}
|
||||||
ORDER BY v.name ASC`,
|
ORDER BY v.name ASC`,
|
||||||
values
|
values
|
||||||
@@ -66,9 +74,12 @@ async function getVorlagen(filter?: { fahrzeug_typ_id?: number; aktiv?: boolean
|
|||||||
async function getVorlageById(id: number) {
|
async function getVorlageById(id: number) {
|
||||||
try {
|
try {
|
||||||
const vorlageResult = await pool.query(
|
const vorlageResult = await pool.query(
|
||||||
`SELECT v.*, ft.name AS fahrzeug_typ_name
|
`SELECT v.*,
|
||||||
|
ft.name AS fahrzeug_typ_name,
|
||||||
|
at.name AS ausruestung_typ_name
|
||||||
FROM checklist_vorlagen v
|
FROM checklist_vorlagen v
|
||||||
LEFT JOIN fahrzeug_typen ft ON ft.id = v.fahrzeug_typ_id
|
LEFT JOIN fahrzeug_typen ft ON ft.id = v.fahrzeug_typ_id
|
||||||
|
LEFT JOIN ausruestung_typen at ON at.id = v.ausruestung_typ_id
|
||||||
WHERE v.id = $1`,
|
WHERE v.id = $1`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
@@ -90,18 +101,24 @@ async function getVorlageById(id: number) {
|
|||||||
async function createVorlage(data: {
|
async function createVorlage(data: {
|
||||||
name: string;
|
name: string;
|
||||||
fahrzeug_typ_id?: number | null;
|
fahrzeug_typ_id?: number | null;
|
||||||
|
fahrzeug_id?: string | null;
|
||||||
|
ausruestung_typ_id?: number | null;
|
||||||
|
ausruestung_id?: string | null;
|
||||||
intervall?: string | null;
|
intervall?: string | null;
|
||||||
intervall_tage?: number | null;
|
intervall_tage?: number | null;
|
||||||
beschreibung?: string | null;
|
beschreibung?: string | null;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO checklist_vorlagen (name, fahrzeug_typ_id, intervall, intervall_tage, beschreibung)
|
`INSERT INTO checklist_vorlagen (name, fahrzeug_typ_id, fahrzeug_id, ausruestung_typ_id, ausruestung_id, intervall, intervall_tage, beschreibung)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
data.name,
|
data.name,
|
||||||
data.fahrzeug_typ_id ?? null,
|
data.fahrzeug_typ_id ?? null,
|
||||||
|
data.fahrzeug_id ?? null,
|
||||||
|
data.ausruestung_typ_id ?? null,
|
||||||
|
data.ausruestung_id ?? null,
|
||||||
data.intervall ?? null,
|
data.intervall ?? null,
|
||||||
data.intervall_tage ?? null,
|
data.intervall_tage ?? null,
|
||||||
data.beschreibung ?? null,
|
data.beschreibung ?? null,
|
||||||
@@ -117,6 +134,9 @@ async function createVorlage(data: {
|
|||||||
async function updateVorlage(id: number, data: {
|
async function updateVorlage(id: number, data: {
|
||||||
name?: string;
|
name?: string;
|
||||||
fahrzeug_typ_id?: number | null;
|
fahrzeug_typ_id?: number | null;
|
||||||
|
fahrzeug_id?: string | null;
|
||||||
|
ausruestung_typ_id?: number | null;
|
||||||
|
ausruestung_id?: string | null;
|
||||||
intervall?: string | null;
|
intervall?: string | null;
|
||||||
intervall_tage?: number | null;
|
intervall_tage?: number | null;
|
||||||
beschreibung?: string | null;
|
beschreibung?: string | null;
|
||||||
@@ -129,6 +149,9 @@ async function updateVorlage(id: number, data: {
|
|||||||
|
|
||||||
if (data.name !== undefined) { setClauses.push(`name = $${idx}`); values.push(data.name); idx++; }
|
if (data.name !== undefined) { setClauses.push(`name = $${idx}`); values.push(data.name); idx++; }
|
||||||
if ('fahrzeug_typ_id' in data) { setClauses.push(`fahrzeug_typ_id = $${idx}`); values.push(data.fahrzeug_typ_id); idx++; }
|
if ('fahrzeug_typ_id' in data) { setClauses.push(`fahrzeug_typ_id = $${idx}`); values.push(data.fahrzeug_typ_id); idx++; }
|
||||||
|
if ('fahrzeug_id' in data) { setClauses.push(`fahrzeug_id = $${idx}`); values.push(data.fahrzeug_id); idx++; }
|
||||||
|
if ('ausruestung_typ_id' in data) { setClauses.push(`ausruestung_typ_id = $${idx}`); values.push(data.ausruestung_typ_id); idx++; }
|
||||||
|
if ('ausruestung_id' in data) { setClauses.push(`ausruestung_id = $${idx}`); values.push(data.ausruestung_id); idx++; }
|
||||||
if ('intervall' in data) { setClauses.push(`intervall = $${idx}`); values.push(data.intervall); idx++; }
|
if ('intervall' 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 ('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 ('beschreibung' in data) { setClauses.push(`beschreibung = $${idx}`); values.push(data.beschreibung); idx++; }
|
||||||
@@ -329,23 +352,111 @@ async function deleteVehicleItem(id: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ausrüstung-spezifische Items
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function getEquipmentItems(ausruestungId: string) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM ausruestung_checklist_items WHERE ausruestung_id = $1 AND aktiv = true ORDER BY sort_order ASC, id ASC`,
|
||||||
|
[ausruestungId]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ChecklistService.getEquipmentItems failed', { error, ausruestungId });
|
||||||
|
throw new Error('Ausrüstungs-Items konnten nicht geladen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addEquipmentItem(ausruestungId: string, data: {
|
||||||
|
bezeichnung: string;
|
||||||
|
beschreibung?: string | null;
|
||||||
|
pflicht?: boolean;
|
||||||
|
sort_order?: number;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO ausruestung_checklist_items (ausruestung_id, bezeichnung, beschreibung, pflicht, sort_order)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *`,
|
||||||
|
[ausruestungId, data.bezeichnung, data.beschreibung ?? null, data.pflicht ?? true, data.sort_order ?? 0]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ChecklistService.addEquipmentItem failed', { error, ausruestungId });
|
||||||
|
throw new Error('Ausrüstungs-Item konnte nicht erstellt werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateEquipmentItem(id: number, data: {
|
||||||
|
bezeichnung?: string;
|
||||||
|
beschreibung?: string | null;
|
||||||
|
pflicht?: boolean;
|
||||||
|
sort_order?: number;
|
||||||
|
aktiv?: boolean;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const setClauses: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (data.bezeichnung !== undefined) { setClauses.push(`bezeichnung = $${idx}`); values.push(data.bezeichnung); idx++; }
|
||||||
|
if ('beschreibung' in data) { setClauses.push(`beschreibung = $${idx}`); values.push(data.beschreibung); idx++; }
|
||||||
|
if (data.pflicht !== undefined) { setClauses.push(`pflicht = $${idx}`); values.push(data.pflicht); idx++; }
|
||||||
|
if (data.sort_order !== undefined) { setClauses.push(`sort_order = $${idx}`); values.push(data.sort_order); idx++; }
|
||||||
|
if (data.aktiv !== undefined) { setClauses.push(`aktiv = $${idx}`); values.push(data.aktiv); idx++; }
|
||||||
|
|
||||||
|
if (setClauses.length === 0) {
|
||||||
|
const r = await pool.query(`SELECT * FROM ausruestung_checklist_items WHERE id = $1`, [id]);
|
||||||
|
return r.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id);
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE ausruestung_checklist_items SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ChecklistService.updateEquipmentItem failed', { error, id });
|
||||||
|
throw new Error('Ausrüstungs-Item konnte nicht aktualisiert werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEquipmentItem(id: number) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE ausruestung_checklist_items SET aktiv = false WHERE id = $1 RETURNING *`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ChecklistService.deleteEquipmentItem failed', { error, id });
|
||||||
|
throw new Error('Ausrüstungs-Item konnte nicht deaktiviert werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Templates for a specific vehicle (via type junction)
|
// Templates for a specific vehicle (via type junction)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function getTemplatesForVehicle(fahrzeugId: string) {
|
async function getTemplatesForVehicle(fahrzeugId: string) {
|
||||||
try {
|
try {
|
||||||
// Templates that match the vehicle's types, or global templates (no type)
|
// Templates that match the vehicle directly, by type, or global (no assignment)
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT DISTINCT v.*, ft.name AS fahrzeug_typ_name
|
`SELECT DISTINCT v.*, ft.name AS fahrzeug_typ_name
|
||||||
FROM checklist_vorlagen v
|
FROM checklist_vorlagen v
|
||||||
LEFT JOIN fahrzeug_typen ft ON ft.id = v.fahrzeug_typ_id
|
LEFT JOIN fahrzeug_typen ft ON ft.id = v.fahrzeug_typ_id
|
||||||
WHERE v.aktiv = true
|
WHERE v.aktiv = true
|
||||||
|
AND v.ausruestung_id IS NULL
|
||||||
|
AND v.ausruestung_typ_id IS NULL
|
||||||
AND (
|
AND (
|
||||||
v.fahrzeug_typ_id IS NULL
|
v.fahrzeug_id = $1
|
||||||
OR v.fahrzeug_typ_id IN (
|
OR v.fahrzeug_typ_id IN (
|
||||||
SELECT fahrzeug_typ_id FROM fahrzeug_fahrzeug_typen WHERE fahrzeug_id = $1
|
SELECT fahrzeug_typ_id FROM fahrzeug_fahrzeug_typen WHERE fahrzeug_id = $1
|
||||||
)
|
)
|
||||||
|
OR (v.fahrzeug_typ_id IS NULL AND v.fahrzeug_id IS NULL)
|
||||||
)
|
)
|
||||||
ORDER BY v.name ASC`,
|
ORDER BY v.name ASC`,
|
||||||
[fahrzeugId]
|
[fahrzeugId]
|
||||||
@@ -367,21 +478,109 @@ async function getTemplatesForVehicle(fahrzeugId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Templates for a specific equipment item (via type junction)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function getTemplatesForEquipment(ausruestungId: string) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT DISTINCT v.*, at.name AS ausruestung_typ_name
|
||||||
|
FROM checklist_vorlagen v
|
||||||
|
LEFT JOIN ausruestung_typen at ON at.id = v.ausruestung_typ_id
|
||||||
|
WHERE v.aktiv = true
|
||||||
|
AND (
|
||||||
|
v.ausruestung_id = $1
|
||||||
|
OR v.ausruestung_typ_id IN (
|
||||||
|
SELECT ausruestung_typ_id FROM ausruestung_ausruestung_typen WHERE ausruestung_id = $1
|
||||||
|
)
|
||||||
|
OR (v.fahrzeug_typ_id IS NULL AND v.fahrzeug_id IS NULL AND v.ausruestung_typ_id IS NULL AND v.ausruestung_id IS NULL)
|
||||||
|
)
|
||||||
|
ORDER BY v.name ASC`,
|
||||||
|
[ausruestungId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Attach items to each template
|
||||||
|
for (const vorlage of result.rows) {
|
||||||
|
const items = await pool.query(
|
||||||
|
`SELECT * FROM checklist_vorlage_items WHERE vorlage_id = $1 ORDER BY sort_order ASC, id ASC`,
|
||||||
|
[vorlage.id]
|
||||||
|
);
|
||||||
|
vorlage.items = items.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ChecklistService.getTemplatesForEquipment failed', { error, ausruestungId });
|
||||||
|
throw new Error('Checklisten für Ausrüstung konnten nicht geladen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Overview Items (vehicles + equipment with open checklists)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function getOverviewItems() {
|
||||||
|
try {
|
||||||
|
// Vehicles with overdue or upcoming checklists (within 7 days)
|
||||||
|
const vehiclesResult = await pool.query(`
|
||||||
|
SELECT f.id, f.bezeichnung AS name, f.kurzname,
|
||||||
|
json_agg(json_build_object(
|
||||||
|
'vorlage_id', cf.vorlage_id,
|
||||||
|
'vorlage_name', v.name,
|
||||||
|
'next_due', cf.naechste_faellig_am
|
||||||
|
) ORDER BY cf.naechste_faellig_am ASC) AS checklists
|
||||||
|
FROM checklist_faelligkeit cf
|
||||||
|
JOIN fahrzeuge f ON f.id = cf.fahrzeug_id AND f.deleted_at IS NULL
|
||||||
|
JOIN checklist_vorlagen v ON v.id = cf.vorlage_id AND v.aktiv = true
|
||||||
|
WHERE cf.fahrzeug_id IS NOT NULL
|
||||||
|
AND cf.naechste_faellig_am <= CURRENT_DATE + INTERVAL '7 days'
|
||||||
|
GROUP BY f.id, f.bezeichnung, f.kurzname
|
||||||
|
ORDER BY MIN(cf.naechste_faellig_am) ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Equipment with overdue or upcoming checklists (within 7 days)
|
||||||
|
const equipmentResult = await pool.query(`
|
||||||
|
SELECT a.id, a.name,
|
||||||
|
json_agg(json_build_object(
|
||||||
|
'vorlage_id', cf.vorlage_id,
|
||||||
|
'vorlage_name', v.name,
|
||||||
|
'next_due', cf.naechste_faellig_am
|
||||||
|
) ORDER BY cf.naechste_faellig_am ASC) AS checklists
|
||||||
|
FROM checklist_faelligkeit cf
|
||||||
|
JOIN ausruestung a ON a.id = cf.ausruestung_id
|
||||||
|
JOIN checklist_vorlagen v ON v.id = cf.vorlage_id AND v.aktiv = true
|
||||||
|
WHERE cf.ausruestung_id IS NOT NULL
|
||||||
|
AND cf.naechste_faellig_am <= CURRENT_DATE + INTERVAL '7 days'
|
||||||
|
GROUP BY a.id, a.name
|
||||||
|
ORDER BY MIN(cf.naechste_faellig_am) ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
vehicles: vehiclesResult.rows,
|
||||||
|
equipment: equipmentResult.rows,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ChecklistService.getOverviewItems failed', { error });
|
||||||
|
throw new Error('Übersichtsdaten konnten nicht geladen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Ausführungen (Executions)
|
// Ausführungen (Executions)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function startExecution(fahrzeugId: string, vorlageId: number, userId: string) {
|
async function startExecution(fahrzeugId: string | null, vorlageId: number, userId: string, ausruestungId?: string | null) {
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
// Create the execution record
|
// Create the execution record
|
||||||
const execResult = await client.query(
|
const execResult = await client.query(
|
||||||
`INSERT INTO checklist_ausfuehrungen (fahrzeug_id, vorlage_id, ausgefuehrt_von)
|
`INSERT INTO checklist_ausfuehrungen (fahrzeug_id, ausruestung_id, vorlage_id, ausgefuehrt_von)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[fahrzeugId, vorlageId, userId]
|
[fahrzeugId || null, ausruestungId || null, vorlageId, userId]
|
||||||
);
|
);
|
||||||
const execution = execResult.rows[0];
|
const execution = execResult.rows[0];
|
||||||
|
|
||||||
@@ -398,17 +597,31 @@ async function startExecution(fahrzeugId: string, vorlageId: number, userId: str
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy vehicle-specific items
|
// Copy entity-specific items (vehicle or equipment)
|
||||||
const vehicleItems = await client.query(
|
if (ausruestungId) {
|
||||||
`SELECT * FROM fahrzeug_checklist_items WHERE fahrzeug_id = $1 AND aktiv = true ORDER BY sort_order ASC, id ASC`,
|
const equipmentItems = await client.query(
|
||||||
[fahrzeugId]
|
`SELECT * FROM ausruestung_checklist_items WHERE ausruestung_id = $1 AND aktiv = true ORDER BY sort_order ASC, id ASC`,
|
||||||
);
|
[ausruestungId]
|
||||||
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]
|
|
||||||
);
|
);
|
||||||
|
for (const item of equipmentItems.rows) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO checklist_ausfuehrung_items (ausfuehrung_id, ausruestung_item_id, bezeichnung)
|
||||||
|
VALUES ($1, $2, $3)`,
|
||||||
|
[execution.id, item.id, item.bezeichnung]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (fahrzeugId) {
|
||||||
|
const vehicleItems = await client.query(
|
||||||
|
`SELECT * FROM fahrzeug_checklist_items WHERE fahrzeug_id = $1 AND aktiv = true ORDER BY sort_order ASC, id ASC`,
|
||||||
|
[fahrzeugId]
|
||||||
|
);
|
||||||
|
for (const item of vehicleItems.rows) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO checklist_ausfuehrung_items (ausfuehrung_id, fahrzeug_item_id, bezeichnung)
|
||||||
|
VALUES ($1, $2, $3)`,
|
||||||
|
[execution.id, item.id, item.bezeichnung]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
@@ -429,11 +642,13 @@ async function getExecutionById(id: string) {
|
|||||||
const execResult = await pool.query(
|
const execResult = await pool.query(
|
||||||
`SELECT a.*,
|
`SELECT a.*,
|
||||||
f.bezeichnung AS fahrzeug_name, f.kurzname AS fahrzeug_kurzname,
|
f.bezeichnung AS fahrzeug_name, f.kurzname AS fahrzeug_kurzname,
|
||||||
|
ar.name AS ausruestung_name,
|
||||||
v.name AS vorlage_name,
|
v.name AS vorlage_name,
|
||||||
u1.name AS ausgefuehrt_von_name,
|
u1.name AS ausgefuehrt_von_name,
|
||||||
u2.name AS freigegeben_von_name
|
u2.name AS freigegeben_von_name
|
||||||
FROM checklist_ausfuehrungen a
|
FROM checklist_ausfuehrungen a
|
||||||
LEFT JOIN fahrzeuge f ON f.id = a.fahrzeug_id
|
LEFT JOIN fahrzeuge f ON f.id = a.fahrzeug_id
|
||||||
|
LEFT JOIN ausruestung ar ON ar.id = a.ausruestung_id
|
||||||
LEFT JOIN checklist_vorlagen v ON v.id = a.vorlage_id
|
LEFT JOIN checklist_vorlagen v ON v.id = a.vorlage_id
|
||||||
LEFT JOIN users u1 ON u1.id = a.ausgefuehrt_von
|
LEFT JOIN users u1 ON u1.id = a.ausgefuehrt_von
|
||||||
LEFT JOIN users u2 ON u2.id = a.freigegeben_von
|
LEFT JOIN users u2 ON u2.id = a.freigegeben_von
|
||||||
@@ -475,12 +690,13 @@ async function submitExecution(
|
|||||||
|
|
||||||
// Check if all pflicht items have ergebnis = 'ok'
|
// Check if all pflicht items have ergebnis = 'ok'
|
||||||
const pflichtCheck = await client.query(
|
const pflichtCheck = await client.query(
|
||||||
`SELECT ai.id, ai.ergebnis, ai.vorlage_item_id, ai.fahrzeug_item_id
|
`SELECT ai.id, ai.ergebnis, ai.vorlage_item_id, ai.fahrzeug_item_id, ai.ausruestung_item_id
|
||||||
FROM checklist_ausfuehrung_items ai
|
FROM checklist_ausfuehrung_items ai
|
||||||
LEFT JOIN checklist_vorlage_items vi ON vi.id = ai.vorlage_item_id
|
LEFT JOIN checklist_vorlage_items vi ON vi.id = ai.vorlage_item_id
|
||||||
LEFT JOIN fahrzeug_checklist_items fi ON fi.id = ai.fahrzeug_item_id
|
LEFT JOIN fahrzeug_checklist_items fi ON fi.id = ai.fahrzeug_item_id
|
||||||
|
LEFT JOIN ausruestung_checklist_items aci ON aci.id = ai.ausruestung_item_id
|
||||||
WHERE ai.ausfuehrung_id = $1
|
WHERE ai.ausfuehrung_id = $1
|
||||||
AND (COALESCE(vi.pflicht, fi.pflicht, true) = true)`,
|
AND (COALESCE(vi.pflicht, fi.pflicht, aci.pflicht, true) = true)`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -494,20 +710,30 @@ async function submitExecution(
|
|||||||
|
|
||||||
// Update checklist_faelligkeit if completed
|
// Update checklist_faelligkeit if completed
|
||||||
if (allPflichtOk) {
|
if (allPflichtOk) {
|
||||||
const exec = await client.query(`SELECT vorlage_id, fahrzeug_id FROM checklist_ausfuehrungen WHERE id = $1`, [id]);
|
const exec = await client.query(`SELECT vorlage_id, fahrzeug_id, ausruestung_id FROM checklist_ausfuehrungen WHERE id = $1`, [id]);
|
||||||
if (exec.rows.length > 0) {
|
if (exec.rows.length > 0) {
|
||||||
const { vorlage_id, fahrzeug_id } = exec.rows[0];
|
const { vorlage_id, fahrzeug_id, ausruestung_id } = exec.rows[0];
|
||||||
const vorlage = await client.query(`SELECT intervall, intervall_tage FROM checklist_vorlagen WHERE id = $1`, [vorlage_id]);
|
const vorlage = await client.query(`SELECT intervall, intervall_tage FROM checklist_vorlagen WHERE id = $1`, [vorlage_id]);
|
||||||
if (vorlage.rows.length > 0) {
|
if (vorlage.rows.length > 0) {
|
||||||
const nextDue = calculateNextDueDate(vorlage.rows[0].intervall, vorlage.rows[0].intervall_tage);
|
const nextDue = calculateNextDueDate(vorlage.rows[0].intervall, vorlage.rows[0].intervall_tage);
|
||||||
if (nextDue) {
|
if (nextDue) {
|
||||||
await client.query(
|
if (ausruestung_id) {
|
||||||
`INSERT INTO checklist_faelligkeit (fahrzeug_id, vorlage_id, naechste_faellig_am, letzte_ausfuehrung_id)
|
await client.query(
|
||||||
VALUES ($1, $2, $3, $4)
|
`INSERT INTO checklist_faelligkeit (ausruestung_id, vorlage_id, naechste_faellig_am, letzte_ausfuehrung_id)
|
||||||
ON CONFLICT (fahrzeug_id, vorlage_id) DO UPDATE
|
VALUES ($1, $2, $3, $4)
|
||||||
SET naechste_faellig_am = $3, letzte_ausfuehrung_id = $4`,
|
ON CONFLICT (vorlage_id, ausruestung_id) WHERE ausruestung_id IS NOT NULL AND fahrzeug_id IS NULL
|
||||||
[fahrzeug_id, vorlage_id, nextDue, id]
|
DO UPDATE SET naechste_faellig_am = $3, letzte_ausfuehrung_id = $4`,
|
||||||
);
|
[ausruestung_id, vorlage_id, nextDue, id]
|
||||||
|
);
|
||||||
|
} else if (fahrzeug_id) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO checklist_faelligkeit (fahrzeug_id, vorlage_id, naechste_faellig_am, letzte_ausfuehrung_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (vorlage_id, fahrzeug_id) WHERE fahrzeug_id IS NOT NULL AND ausruestung_id IS NULL
|
||||||
|
DO UPDATE SET naechste_faellig_am = $3, letzte_ausfuehrung_id = $4`,
|
||||||
|
[fahrzeug_id, vorlage_id, nextDue, id]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -541,7 +767,7 @@ async function approveExecution(id: string, userId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getExecutions(filter?: { fahrzeugId?: string; vorlageId?: number; status?: string }) {
|
async function getExecutions(filter?: { fahrzeugId?: string; ausruestungId?: string; vorlageId?: number; status?: string }) {
|
||||||
try {
|
try {
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
const values: any[] = [];
|
const values: any[] = [];
|
||||||
@@ -552,6 +778,11 @@ async function getExecutions(filter?: { fahrzeugId?: string; vorlageId?: number;
|
|||||||
values.push(filter.fahrzeugId);
|
values.push(filter.fahrzeugId);
|
||||||
idx++;
|
idx++;
|
||||||
}
|
}
|
||||||
|
if (filter?.ausruestungId) {
|
||||||
|
conditions.push(`a.ausruestung_id = $${idx}`);
|
||||||
|
values.push(filter.ausruestungId);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
if (filter?.vorlageId) {
|
if (filter?.vorlageId) {
|
||||||
conditions.push(`a.vorlage_id = $${idx}`);
|
conditions.push(`a.vorlage_id = $${idx}`);
|
||||||
values.push(filter.vorlageId);
|
values.push(filter.vorlageId);
|
||||||
@@ -567,11 +798,13 @@ async function getExecutions(filter?: { fahrzeugId?: string; vorlageId?: number;
|
|||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT a.*,
|
`SELECT a.*,
|
||||||
f.bezeichnung AS fahrzeug_name, f.kurzname AS fahrzeug_kurzname,
|
f.bezeichnung AS fahrzeug_name, f.kurzname AS fahrzeug_kurzname,
|
||||||
|
ar.name AS ausruestung_name,
|
||||||
v.name AS vorlage_name,
|
v.name AS vorlage_name,
|
||||||
u1.name AS ausgefuehrt_von_name,
|
u1.name AS ausgefuehrt_von_name,
|
||||||
u2.name AS freigegeben_von_name
|
u2.name AS freigegeben_von_name
|
||||||
FROM checklist_ausfuehrungen a
|
FROM checklist_ausfuehrungen a
|
||||||
LEFT JOIN fahrzeuge f ON f.id = a.fahrzeug_id
|
LEFT JOIN fahrzeuge f ON f.id = a.fahrzeug_id
|
||||||
|
LEFT JOIN ausruestung ar ON ar.id = a.ausruestung_id
|
||||||
LEFT JOIN checklist_vorlagen v ON v.id = a.vorlage_id
|
LEFT JOIN checklist_vorlagen v ON v.id = a.vorlage_id
|
||||||
LEFT JOIN users u1 ON u1.id = a.ausgefuehrt_von
|
LEFT JOIN users u1 ON u1.id = a.ausgefuehrt_von
|
||||||
LEFT JOIN users u2 ON u2.id = a.freigegeben_von
|
LEFT JOIN users u2 ON u2.id = a.freigegeben_von
|
||||||
@@ -593,12 +826,17 @@ async function getExecutions(filter?: { fahrzeugId?: string; vorlageId?: number;
|
|||||||
async function getOverdueChecklists() {
|
async function getOverdueChecklists() {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
SELECT cf.*, f.bezeichnung AS fahrzeug_name, f.kurzname AS fahrzeug_kurzname,
|
SELECT cf.*,
|
||||||
|
f.bezeichnung AS fahrzeug_name, f.kurzname AS fahrzeug_kurzname,
|
||||||
|
ar.name AS ausruestung_name,
|
||||||
v.name AS vorlage_name
|
v.name AS vorlage_name
|
||||||
FROM checklist_faelligkeit cf
|
FROM checklist_faelligkeit cf
|
||||||
JOIN fahrzeuge f ON f.id = cf.fahrzeug_id AND f.deleted_at IS NULL
|
LEFT JOIN fahrzeuge f ON f.id = cf.fahrzeug_id AND f.deleted_at IS NULL
|
||||||
|
LEFT JOIN ausruestung ar ON ar.id = cf.ausruestung_id
|
||||||
JOIN checklist_vorlagen v ON v.id = cf.vorlage_id AND v.aktiv = true
|
JOIN checklist_vorlagen v ON v.id = cf.vorlage_id AND v.aktiv = true
|
||||||
WHERE cf.naechste_faellig_am <= CURRENT_DATE
|
WHERE cf.naechste_faellig_am <= CURRENT_DATE
|
||||||
|
AND (cf.fahrzeug_id IS NOT NULL OR cf.ausruestung_id IS NOT NULL)
|
||||||
|
AND (cf.fahrzeug_id IS NULL OR f.id IS NOT NULL)
|
||||||
ORDER BY cf.naechste_faellig_am ASC
|
ORDER BY cf.naechste_faellig_am ASC
|
||||||
`);
|
`);
|
||||||
return result.rows;
|
return result.rows;
|
||||||
@@ -639,7 +877,13 @@ export default {
|
|||||||
addVehicleItem,
|
addVehicleItem,
|
||||||
updateVehicleItem,
|
updateVehicleItem,
|
||||||
deleteVehicleItem,
|
deleteVehicleItem,
|
||||||
|
getEquipmentItems,
|
||||||
|
addEquipmentItem,
|
||||||
|
updateEquipmentItem,
|
||||||
|
deleteEquipmentItem,
|
||||||
getTemplatesForVehicle,
|
getTemplatesForVehicle,
|
||||||
|
getTemplatesForEquipment,
|
||||||
|
getOverviewItems,
|
||||||
startExecution,
|
startExecution,
|
||||||
getExecutionById,
|
getExecutionById,
|
||||||
submitExecution,
|
submitExecution,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import BookingFormPage from './pages/BookingFormPage';
|
|||||||
import Ausruestung from './pages/Ausruestung';
|
import Ausruestung from './pages/Ausruestung';
|
||||||
import AusruestungForm from './pages/AusruestungForm';
|
import AusruestungForm from './pages/AusruestungForm';
|
||||||
import AusruestungDetail from './pages/AusruestungDetail';
|
import AusruestungDetail from './pages/AusruestungDetail';
|
||||||
|
import AusruestungEinstellungen from './pages/AusruestungEinstellungen';
|
||||||
import Atemschutz from './pages/Atemschutz';
|
import Atemschutz from './pages/Atemschutz';
|
||||||
import Mitglieder from './pages/Mitglieder';
|
import Mitglieder from './pages/Mitglieder';
|
||||||
import MitgliedDetail from './pages/MitgliedDetail';
|
import MitgliedDetail from './pages/MitgliedDetail';
|
||||||
@@ -37,6 +38,7 @@ import AusruestungsanfrageZuBestellung from './pages/AusruestungsanfrageZuBestel
|
|||||||
import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail';
|
import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail';
|
||||||
import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu';
|
import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu';
|
||||||
import Checklisten from './pages/Checklisten';
|
import Checklisten from './pages/Checklisten';
|
||||||
|
import FahrzeugEinstellungen from './pages/FahrzeugEinstellungen';
|
||||||
import ChecklistAusfuehrung from './pages/ChecklistAusfuehrung';
|
import ChecklistAusfuehrung from './pages/ChecklistAusfuehrung';
|
||||||
import Issues from './pages/Issues';
|
import Issues from './pages/Issues';
|
||||||
import IssueDetail from './pages/IssueDetail';
|
import IssueDetail from './pages/IssueDetail';
|
||||||
@@ -120,6 +122,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/fahrzeuge/einstellungen"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<FahrzeugEinstellungen />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/fahrzeuge/:id/bearbeiten"
|
path="/fahrzeuge/:id/bearbeiten"
|
||||||
element={
|
element={
|
||||||
@@ -152,6 +162,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/ausruestung/einstellungen"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AusruestungEinstellungen />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/ausruestung/:id/bearbeiten"
|
path="/ausruestung/:id/bearbeiten"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -63,10 +63,11 @@ function ChecklistWidget() {
|
|||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
{overdueItems.slice(0, 5).map((item) => {
|
{overdueItems.slice(0, 5).map((item) => {
|
||||||
const days = Math.ceil((Date.now() - new Date(item.naechste_faellig_am).getTime()) / 86400000);
|
const days = Math.ceil((Date.now() - new Date(item.naechste_faellig_am).getTime()) / 86400000);
|
||||||
|
const targetName = item.fahrzeug_name || item.ausruestung_name || '–';
|
||||||
return (
|
return (
|
||||||
<Box key={`${item.fahrzeug_id}-${item.vorlage_id}`} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<Box key={`${item.fahrzeug_id || item.ausruestung_id}-${item.vorlage_id}`} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<Typography variant="body2" noWrap sx={{ maxWidth: '60%' }}>
|
<Typography variant="body2" noWrap sx={{ maxWidth: '60%' }}>
|
||||||
{item.fahrzeug_name}
|
{targetName}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip
|
<Chip
|
||||||
label={`${item.vorlage_name} \u2013 ${days > 0 ? `${days}d` : 'heute'}`}
|
label={`${item.vorlage_name} \u2013 ${days > 0 ? `${days}d` : 'heute'}`}
|
||||||
|
|||||||
@@ -192,12 +192,17 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|||||||
const menuOrder: string[] = (preferences?.menuOrder as string[] | undefined) ?? [];
|
const menuOrder: string[] = (preferences?.menuOrder as string[] | undefined) ?? [];
|
||||||
|
|
||||||
const vehicleSubItems: SubItem[] = useMemo(
|
const vehicleSubItems: SubItem[] = useMemo(
|
||||||
() =>
|
() => {
|
||||||
(vehicleList ?? []).map((v) => ({
|
const items: SubItem[] = (vehicleList ?? []).map((v) => ({
|
||||||
text: v.bezeichnung ?? v.kurzname,
|
text: v.bezeichnung ?? v.kurzname,
|
||||||
path: `/fahrzeuge/${v.id}`,
|
path: `/fahrzeuge/${v.id}`,
|
||||||
})),
|
}));
|
||||||
[vehicleList],
|
if (hasPermission('fahrzeuge:edit')) {
|
||||||
|
items.push({ text: 'Einstellungen', path: '/fahrzeuge/einstellungen' });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
[vehicleList, hasPermission],
|
||||||
);
|
);
|
||||||
|
|
||||||
const navigationItems = useMemo((): NavigationItem[] => {
|
const navigationItems = useMemo((): NavigationItem[] => {
|
||||||
@@ -234,13 +239,19 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|||||||
];
|
];
|
||||||
if (hasPermission('checklisten:manage_templates')) {
|
if (hasPermission('checklisten:manage_templates')) {
|
||||||
checklistenSubItems.push({ text: 'Vorlagen', path: '/checklisten?tab=1' });
|
checklistenSubItems.push({ text: 'Vorlagen', path: '/checklisten?tab=1' });
|
||||||
checklistenSubItems.push({ text: 'Fahrzeugtypen', path: '/checklisten?tab=2' });
|
|
||||||
}
|
}
|
||||||
checklistenSubItems.push({ text: 'Historie', path: `/checklisten?tab=${checklistenSubItems.length}` });
|
checklistenSubItems.push({ text: 'Historie', path: `/checklisten?tab=${checklistenSubItems.length}` });
|
||||||
|
|
||||||
const items = baseNavigationItems
|
const items = baseNavigationItems
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
if (item.path === '/fahrzeuge') return fahrzeugeItem;
|
if (item.path === '/fahrzeuge') return fahrzeugeItem;
|
||||||
|
if (item.path === '/ausruestung') {
|
||||||
|
const ausruestungSubs: SubItem[] = [];
|
||||||
|
if (hasPermission('ausruestung:manage_types')) {
|
||||||
|
ausruestungSubs.push({ text: 'Einstellungen', path: '/ausruestung/einstellungen' });
|
||||||
|
}
|
||||||
|
return ausruestungSubs.length > 0 ? { ...item, subItems: ausruestungSubs } : item;
|
||||||
|
}
|
||||||
if (item.path === '/ausruestungsanfrage') {
|
if (item.path === '/ausruestungsanfrage') {
|
||||||
const canSeeAusruestung =
|
const canSeeAusruestung =
|
||||||
hasPermission('ausruestungsanfrage:view') ||
|
hasPermission('ausruestungsanfrage:view') ||
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ import {
|
|||||||
Chip,
|
Chip,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Container,
|
Container,
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
FormControl,
|
FormControl,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
Grid,
|
Grid,
|
||||||
@@ -22,12 +18,6 @@ import {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
Select,
|
Select,
|
||||||
Switch,
|
Switch,
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TextField,
|
TextField,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -36,22 +26,20 @@ import {
|
|||||||
Add,
|
Add,
|
||||||
Build,
|
Build,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Close,
|
|
||||||
Delete,
|
|
||||||
Edit,
|
|
||||||
Error as ErrorIcon,
|
Error as ErrorIcon,
|
||||||
LinkRounded,
|
LinkRounded,
|
||||||
PauseCircle,
|
PauseCircle,
|
||||||
RemoveCircle,
|
RemoveCircle,
|
||||||
Save,
|
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
Star,
|
Star,
|
||||||
Warning,
|
Warning,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { equipmentApi } from '../services/equipment';
|
import { equipmentApi } from '../services/equipment';
|
||||||
|
import { ausruestungTypenApi } from '../services/ausruestungTypen';
|
||||||
import {
|
import {
|
||||||
AusruestungListItem,
|
AusruestungListItem,
|
||||||
AusruestungKategorie,
|
AusruestungKategorie,
|
||||||
@@ -60,7 +48,6 @@ import {
|
|||||||
EquipmentStats,
|
EquipmentStats,
|
||||||
} from '../types/equipment.types';
|
} from '../types/equipment.types';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
|
||||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||||
|
|
||||||
// ── Status chip config ────────────────────────────────────────────────────────
|
// ── Status chip config ────────────────────────────────────────────────────────
|
||||||
@@ -169,13 +156,23 @@ const EquipmentCard: React.FC<EquipmentCardProps> = ({ item, onClick }) => {
|
|||||||
<Typography variant="h6" component="div" lineHeight={1.2} noWrap>
|
<Typography variant="h6" component="div" lineHeight={1.2} noWrap>
|
||||||
{item.bezeichnung}
|
{item.bezeichnung}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5, flexWrap: 'wrap' }}>
|
||||||
<Chip
|
<Chip
|
||||||
label={item.kategorie_kurzname}
|
label={item.kategorie_kurzname}
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
sx={{ fontSize: '0.7rem' }}
|
sx={{ fontSize: '0.7rem' }}
|
||||||
/>
|
/>
|
||||||
|
{item.typen?.map((t) => (
|
||||||
|
<Chip
|
||||||
|
key={t.id}
|
||||||
|
label={t.name}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ fontSize: '0.7rem' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -236,186 +233,12 @@ const EquipmentCard: React.FC<EquipmentCardProps> = ({ item, onClick }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Category Management Dialog ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface CategoryDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
categories: AusruestungKategorie[];
|
|
||||||
onRefresh: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CategoryManagementDialog: React.FC<CategoryDialogProps> = ({ open, onClose, categories, onRefresh }) => {
|
|
||||||
const { showSuccess, showError } = useNotification();
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
|
||||||
const [editName, setEditName] = useState('');
|
|
||||||
const [editKurzname, setEditKurzname] = useState('');
|
|
||||||
const [editMotor, setEditMotor] = useState(false);
|
|
||||||
const [newName, setNewName] = useState('');
|
|
||||||
const [newKurzname, setNewKurzname] = useState('');
|
|
||||||
const [newMotor, setNewMotor] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const startEdit = (cat: AusruestungKategorie) => {
|
|
||||||
setEditingId(cat.id);
|
|
||||||
setEditName(cat.name);
|
|
||||||
setEditKurzname(cat.kurzname);
|
|
||||||
setEditMotor(cat.motorisiert);
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelEdit = () => {
|
|
||||||
setEditingId(null);
|
|
||||||
setEditName('');
|
|
||||||
setEditKurzname('');
|
|
||||||
setEditMotor(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveEdit = async () => {
|
|
||||||
if (!editingId || !editName.trim() || !editKurzname.trim()) return;
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await equipmentApi.updateCategory(editingId, { name: editName.trim(), kurzname: editKurzname.trim(), motorisiert: editMotor });
|
|
||||||
showSuccess('Kategorie aktualisiert');
|
|
||||||
cancelEdit();
|
|
||||||
onRefresh();
|
|
||||||
} catch {
|
|
||||||
showError('Kategorie konnte nicht aktualisiert werden');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
|
||||||
if (!newName.trim() || !newKurzname.trim()) return;
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await equipmentApi.createCategory({ name: newName.trim(), kurzname: newKurzname.trim(), motorisiert: newMotor });
|
|
||||||
showSuccess('Kategorie erstellt');
|
|
||||||
setNewName('');
|
|
||||||
setNewKurzname('');
|
|
||||||
setNewMotor(false);
|
|
||||||
onRefresh();
|
|
||||||
} catch {
|
|
||||||
showError('Kategorie konnte nicht erstellt werden');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await equipmentApi.deleteCategory(id);
|
|
||||||
showSuccess('Kategorie gelöscht');
|
|
||||||
onRefresh();
|
|
||||||
} catch (err: any) {
|
|
||||||
const msg = err?.response?.data?.message || 'Kategorie konnte nicht gelöscht werden';
|
|
||||||
showError(msg);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
|
||||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
||||||
Kategorien verwalten
|
|
||||||
<IconButton onClick={onClose} size="small"><Close /></IconButton>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
|
||||||
<TableContainer>
|
|
||||||
<Table size="small">
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>Name</TableCell>
|
|
||||||
<TableCell>Kurzname</TableCell>
|
|
||||||
<TableCell>Motorisiert</TableCell>
|
|
||||||
<TableCell align="right">Aktionen</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<TableRow key={cat.id}>
|
|
||||||
{editingId === cat.id ? (
|
|
||||||
<>
|
|
||||||
<TableCell>
|
|
||||||
<TextField size="small" value={editName} onChange={(e) => setEditName(e.target.value)} fullWidth />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<TextField size="small" value={editKurzname} onChange={(e) => setEditKurzname(e.target.value)} fullWidth />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Switch checked={editMotor} onChange={(e) => setEditMotor(e.target.checked)} size="small" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="right">
|
|
||||||
<IconButton size="small" onClick={saveEdit} disabled={saving || !editName.trim() || !editKurzname.trim()} color="primary">
|
|
||||||
<Save fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton size="small" onClick={cancelEdit} disabled={saving}>
|
|
||||||
<Close fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<TableCell>{cat.name}</TableCell>
|
|
||||||
<TableCell>{cat.kurzname}</TableCell>
|
|
||||||
<TableCell>{cat.motorisiert ? 'Ja' : 'Nein'}</TableCell>
|
|
||||||
<TableCell align="right">
|
|
||||||
<IconButton size="small" onClick={() => startEdit(cat)} disabled={saving}>
|
|
||||||
<Edit fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton size="small" onClick={() => handleDelete(cat.id)} disabled={saving} color="error">
|
|
||||||
<Delete fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
{/* New category row */}
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>
|
|
||||||
<TextField size="small" placeholder="Name" value={newName} onChange={(e) => setNewName(e.target.value)} fullWidth />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<TextField size="small" placeholder="Kurzname" value={newKurzname} onChange={(e) => setNewKurzname(e.target.value)} fullWidth />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Switch checked={newMotor} onChange={(e) => setNewMotor(e.target.checked)} size="small" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="right">
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
size="small"
|
|
||||||
startIcon={<Add />}
|
|
||||||
onClick={handleCreate}
|
|
||||||
disabled={saving || !newName.trim() || !newKurzname.trim()}
|
|
||||||
>
|
|
||||||
Hinzufügen
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onClose}>Schließen</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Ausruestung() {
|
function Ausruestung() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { canManageEquipment, hasPermission } = usePermissions();
|
const { canManageEquipment, hasPermission } = usePermissions();
|
||||||
const canManageCategories = hasPermission('ausruestung:manage_categories');
|
const canManageTypes = hasPermission('ausruestung:manage_types');
|
||||||
|
|
||||||
// Category dialog state
|
|
||||||
const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
// Data state
|
// Data state
|
||||||
const [equipment, setEquipment] = useState<AusruestungListItem[]>([]);
|
const [equipment, setEquipment] = useState<AusruestungListItem[]>([]);
|
||||||
@@ -424,9 +247,16 @@ function Ausruestung() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Equipment types for filter
|
||||||
|
const { data: typen = [] } = useQuery({
|
||||||
|
queryKey: ['ausruestungTypen'],
|
||||||
|
queryFn: ausruestungTypenApi.getAll,
|
||||||
|
});
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selectedCategory, setSelectedCategory] = useState('');
|
const [selectedCategory, setSelectedCategory] = useState('');
|
||||||
|
const [selectedTyp, setSelectedTyp] = useState('');
|
||||||
const [selectedStatus, setSelectedStatus] = useState('');
|
const [selectedStatus, setSelectedStatus] = useState('');
|
||||||
const [nurWichtige, setNurWichtige] = useState(false);
|
const [nurWichtige, setNurWichtige] = useState(false);
|
||||||
const [pruefungFaellig, setPruefungFaellig] = useState(false);
|
const [pruefungFaellig, setPruefungFaellig] = useState(false);
|
||||||
@@ -471,6 +301,14 @@ function Ausruestung() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Type filter
|
||||||
|
if (selectedTyp) {
|
||||||
|
const typId = parseInt(selectedTyp, 10);
|
||||||
|
if (!item.typen?.some((t) => t.id === typId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Status filter
|
// Status filter
|
||||||
if (selectedStatus && item.status !== selectedStatus) {
|
if (selectedStatus && item.status !== selectedStatus) {
|
||||||
return false;
|
return false;
|
||||||
@@ -490,7 +328,7 @@ function Ausruestung() {
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [equipment, search, selectedCategory, selectedStatus, nurWichtige, pruefungFaellig]);
|
}, [equipment, search, selectedCategory, selectedTyp, selectedStatus, nurWichtige, pruefungFaellig]);
|
||||||
|
|
||||||
const hasOverdue = equipment.some(
|
const hasOverdue = equipment.some(
|
||||||
(item) => item.pruefung_tage_bis_faelligkeit !== null && item.pruefung_tage_bis_faelligkeit < 0
|
(item) => item.pruefung_tage_bis_faelligkeit !== null && item.pruefung_tage_bis_faelligkeit < 0
|
||||||
@@ -506,9 +344,9 @@ function Ausruestung() {
|
|||||||
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
|
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
|
||||||
Ausrüstungsverwaltung
|
Ausrüstungsverwaltung
|
||||||
</Typography>
|
</Typography>
|
||||||
{canManageCategories && (
|
{canManageTypes && (
|
||||||
<Tooltip title="Kategorien verwalten">
|
<Tooltip title="Einstellungen">
|
||||||
<IconButton onClick={() => setCategoryDialogOpen(true)} size="small">
|
<IconButton onClick={() => navigate('/ausruestung/einstellungen')} size="small">
|
||||||
<Settings />
|
<Settings />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -576,6 +414,22 @@ function Ausruestung() {
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||||
|
<InputLabel>Typ</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={selectedTyp}
|
||||||
|
label="Typ"
|
||||||
|
onChange={(e) => setSelectedTyp(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="">Alle Typen</MenuItem>
|
||||||
|
{typen.map((t) => (
|
||||||
|
<MenuItem key={t.id} value={String(t.id)}>
|
||||||
|
{t.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||||
<InputLabel>Status</InputLabel>
|
<InputLabel>Status</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
@@ -673,15 +527,6 @@ function Ausruestung() {
|
|||||||
<Add />
|
<Add />
|
||||||
</ChatAwareFab>
|
</ChatAwareFab>
|
||||||
)}
|
)}
|
||||||
{/* Category management dialog */}
|
|
||||||
{canManageCategories && (
|
|
||||||
<CategoryManagementDialog
|
|
||||||
open={categoryDialogOpen}
|
|
||||||
onClose={() => setCategoryDialogOpen(false)}
|
|
||||||
categories={categories}
|
|
||||||
onRefresh={fetchData}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Container>
|
</Container>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Autocomplete,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
@@ -44,14 +45,18 @@ import {
|
|||||||
MoreHoriz,
|
MoreHoriz,
|
||||||
PauseCircle,
|
PauseCircle,
|
||||||
RemoveCircle,
|
RemoveCircle,
|
||||||
|
Save,
|
||||||
Star,
|
Star,
|
||||||
Verified,
|
Verified,
|
||||||
Warning,
|
Warning,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom';
|
import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||||
import { equipmentApi } from '../services/equipment';
|
import { equipmentApi } from '../services/equipment';
|
||||||
|
import { ausruestungTypenApi } from '../services/ausruestungTypen';
|
||||||
|
import type { AusruestungTyp } from '../services/ausruestungTypen';
|
||||||
import { fromGermanDate } from '../utils/dateInput';
|
import { fromGermanDate } from '../utils/dateInput';
|
||||||
import {
|
import {
|
||||||
AusruestungDetail,
|
AusruestungDetail,
|
||||||
@@ -197,9 +202,10 @@ interface UebersichtTabProps {
|
|||||||
equipment: AusruestungDetail;
|
equipment: AusruestungDetail;
|
||||||
onStatusUpdated: () => void;
|
onStatusUpdated: () => void;
|
||||||
canChangeStatus: boolean;
|
canChangeStatus: boolean;
|
||||||
|
canWrite: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UebersichtTab: React.FC<UebersichtTabProps> = ({ equipment, onStatusUpdated, canChangeStatus }) => {
|
const UebersichtTab: React.FC<UebersichtTabProps> = ({ equipment, onStatusUpdated, canChangeStatus, canWrite }) => {
|
||||||
const [statusDialogOpen, setStatusDialogOpen] = useState(false);
|
const [statusDialogOpen, setStatusDialogOpen] = useState(false);
|
||||||
const [newStatus, setNewStatus] = useState<AusruestungStatus>(equipment.status);
|
const [newStatus, setNewStatus] = useState<AusruestungStatus>(equipment.status);
|
||||||
const [bemerkung, setBemerkung] = useState(equipment.status_bemerkung ?? '');
|
const [bemerkung, setBemerkung] = useState(equipment.status_bemerkung ?? '');
|
||||||
@@ -406,6 +412,109 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ equipment, onStatusUpdate
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<StatusHistorySection equipmentId={equipment.id} />
|
<StatusHistorySection equipmentId={equipment.id} />
|
||||||
|
|
||||||
|
<TypenSection equipmentId={equipment.id} canWrite={canWrite} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- Typen Section (equipment type assignment) --------------------------------
|
||||||
|
|
||||||
|
interface TypenSectionProps {
|
||||||
|
equipmentId: string;
|
||||||
|
canWrite: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TypenSection: React.FC<TypenSectionProps> = ({ equipmentId, canWrite }) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [selectedTypen, setSelectedTypen] = useState<AusruestungTyp[]>([]);
|
||||||
|
|
||||||
|
const { data: assignedTypen = [] } = useQuery({
|
||||||
|
queryKey: ['ausruestungTypen', 'equipment', equipmentId],
|
||||||
|
queryFn: () => ausruestungTypenApi.getTypesForEquipment(equipmentId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: allTypen = [] } = useQuery({
|
||||||
|
queryKey: ['ausruestungTypen'],
|
||||||
|
queryFn: ausruestungTypenApi.getAll,
|
||||||
|
enabled: editing,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (typIds: number[]) =>
|
||||||
|
ausruestungTypenApi.setTypesForEquipment(equipmentId, typIds),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['ausruestungTypen', 'equipment', equipmentId] });
|
||||||
|
showSuccess('Typen aktualisiert');
|
||||||
|
setEditing(false);
|
||||||
|
},
|
||||||
|
onError: () => showError('Typen konnten nicht gespeichert werden'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const startEditing = () => {
|
||||||
|
setSelectedTypen(assignedTypen);
|
||||||
|
setEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
saveMutation.mutate(selectedTypen.map((t) => t.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||||
|
<Typography variant="h6">Typen</Typography>
|
||||||
|
{canWrite && !editing && (
|
||||||
|
<Button size="small" startIcon={<Edit />} onClick={startEditing}>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{editing ? (
|
||||||
|
<Box>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
options={allTypen}
|
||||||
|
getOptionLabel={(o) => o.name}
|
||||||
|
value={selectedTypen}
|
||||||
|
onChange={(_, newVal) => setSelectedTypen(newVal)}
|
||||||
|
isOptionEqualToValue={(o, v) => o.id === v.id}
|
||||||
|
renderTags={(value, getTagProps) =>
|
||||||
|
value.map((option, index) => (
|
||||||
|
<Chip label={option.name} size="small" {...getTagProps({ index })} key={option.id} />
|
||||||
|
))
|
||||||
|
}
|
||||||
|
renderInput={(params) => <TextField {...params} label="Typen zuordnen" size="small" />}
|
||||||
|
sx={{ mb: 1 }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={saveMutation.isPending ? <CircularProgress size={14} /> : <Save />}
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={() => setEditing(false)} disabled={saveMutation.isPending}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
|
{assignedTypen.length === 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">Keine Typen zugeordnet</Typography>
|
||||||
|
) : (
|
||||||
|
assignedTypen.map((t) => (
|
||||||
|
<Chip key={t.id} label={t.name} size="small" variant="outlined" />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -845,6 +954,7 @@ function AusruestungDetailPage() {
|
|||||||
equipment={equipment}
|
equipment={equipment}
|
||||||
onStatusUpdated={fetchEquipment}
|
onStatusUpdated={fetchEquipment}
|
||||||
canChangeStatus={canWrite}
|
canChangeStatus={canWrite}
|
||||||
|
canWrite={canWrite}
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
|
|||||||
310
frontend/src/pages/AusruestungEinstellungen.tsx
Normal file
310
frontend/src/pages/AusruestungEinstellungen.tsx
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Add, ArrowBack, Delete, Edit, Save, Close } from '@mui/icons-material';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
|
import { ausruestungTypenApi, AusruestungTyp } from '../services/ausruestungTypen';
|
||||||
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
|
|
||||||
|
function AusruestungEinstellungen() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { hasPermission } = usePermissions();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const canManageTypes = hasPermission('ausruestung:manage_types');
|
||||||
|
|
||||||
|
// Data
|
||||||
|
const { data: typen = [], isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['ausruestungTypen'],
|
||||||
|
queryFn: ausruestungTypenApi.getAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dialog state
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingTyp, setEditingTyp] = useState<AusruestungTyp | null>(null);
|
||||||
|
const [formName, setFormName] = useState('');
|
||||||
|
const [formBeschreibung, setFormBeschreibung] = useState('');
|
||||||
|
const [formIcon, setFormIcon] = useState('');
|
||||||
|
|
||||||
|
// Delete dialog state
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [deletingTyp, setDeletingTyp] = useState<AusruestungTyp | null>(null);
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: { name: string; beschreibung?: string; icon?: string }) =>
|
||||||
|
ausruestungTypenApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['ausruestungTypen'] });
|
||||||
|
showSuccess('Typ erstellt');
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
onError: () => showError('Typ konnte nicht erstellt werden'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: Partial<{ name: string; beschreibung: string; icon: string }> }) =>
|
||||||
|
ausruestungTypenApi.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['ausruestungTypen'] });
|
||||||
|
showSuccess('Typ aktualisiert');
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
onError: () => showError('Typ konnte nicht aktualisiert werden'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => ausruestungTypenApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['ausruestungTypen'] });
|
||||||
|
showSuccess('Typ gelöscht');
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setDeletingTyp(null);
|
||||||
|
},
|
||||||
|
onError: () => showError('Typ konnte nicht gelöscht werden. Möglicherweise ist er noch Geräten zugeordnet.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const openAddDialog = () => {
|
||||||
|
setEditingTyp(null);
|
||||||
|
setFormName('');
|
||||||
|
setFormBeschreibung('');
|
||||||
|
setFormIcon('');
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditDialog = (typ: AusruestungTyp) => {
|
||||||
|
setEditingTyp(typ);
|
||||||
|
setFormName(typ.name);
|
||||||
|
setFormBeschreibung(typ.beschreibung ?? '');
|
||||||
|
setFormIcon(typ.icon ?? '');
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setEditingTyp(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!formName.trim()) return;
|
||||||
|
const data = {
|
||||||
|
name: formName.trim(),
|
||||||
|
beschreibung: formBeschreibung.trim() || undefined,
|
||||||
|
icon: formIcon.trim() || undefined,
|
||||||
|
};
|
||||||
|
if (editingTyp) {
|
||||||
|
updateMutation.mutate({ id: editingTyp.id, data });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDeleteDialog = (typ: AusruestungTyp) => {
|
||||||
|
setDeletingTyp(typ);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
// Permission guard
|
||||||
|
if (!canManageTypes) {
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Container maxWidth="lg">
|
||||||
|
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||||
|
<Typography variant="h5" gutterBottom>Keine Berechtigung</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
Sie haben nicht die erforderlichen Rechte, um Ausrüstungstypen zu verwalten.
|
||||||
|
</Typography>
|
||||||
|
<Button variant="contained" onClick={() => navigate('/ausruestung')}>
|
||||||
|
Zurück zur Ausrüstungsübersicht
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Container maxWidth="lg">
|
||||||
|
<Button
|
||||||
|
startIcon={<ArrowBack />}
|
||||||
|
onClick={() => navigate('/ausruestung')}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Ausrüstungsübersicht
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||||
|
<Typography variant="h4">Ausrüstungs-Einstellungen</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Ausrüstungstypen Section */}
|
||||||
|
<Typography variant="h6" gutterBottom>Ausrüstungstypen</Typography>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
Typen konnten nicht geladen werden.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && (
|
||||||
|
<Paper variant="outlined">
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Beschreibung</TableCell>
|
||||||
|
<TableCell>Icon</TableCell>
|
||||||
|
<TableCell align="right">Aktionen</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{typen.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} align="center">
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
|
||||||
|
Noch keine Typen vorhanden.
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{typen.map((typ) => (
|
||||||
|
<TableRow key={typ.id}>
|
||||||
|
<TableCell>{typ.name}</TableCell>
|
||||||
|
<TableCell>{typ.beschreibung || '---'}</TableCell>
|
||||||
|
<TableCell>{typ.icon || '---'}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Tooltip title="Bearbeiten">
|
||||||
|
<IconButton size="small" onClick={() => openEditDialog(typ)}>
|
||||||
|
<Edit fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Löschen">
|
||||||
|
<IconButton size="small" color="error" onClick={() => openDeleteDialog(typ)}>
|
||||||
|
<Delete fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<Box sx={{ p: 1.5, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button variant="contained" size="small" startIcon={<Add />} onClick={openAddDialog}>
|
||||||
|
Neuer Typ
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add/Edit dialog */}
|
||||||
|
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
{editingTyp ? 'Typ bearbeiten' : 'Neuen Typ erstellen'}
|
||||||
|
<IconButton onClick={closeDialog} size="small"><Close /></IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
label="Name *"
|
||||||
|
fullWidth
|
||||||
|
value={formName}
|
||||||
|
onChange={(e) => setFormName(e.target.value)}
|
||||||
|
sx={{ mt: 1, mb: 2 }}
|
||||||
|
inputProps={{ maxLength: 100 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Beschreibung"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={formBeschreibung}
|
||||||
|
onChange={(e) => setFormBeschreibung(e.target.value)}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Icon (MUI Icon-Name)"
|
||||||
|
fullWidth
|
||||||
|
value={formIcon}
|
||||||
|
onChange={(e) => setFormIcon(e.target.value)}
|
||||||
|
placeholder="z.B. Build, LocalFireDepartment"
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={closeDialog}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving || !formName.trim()}
|
||||||
|
startIcon={isSaving ? <CircularProgress size={16} /> : <Save />}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
<Dialog open={deleteDialogOpen} onClose={() => !deleteMutation.isPending && setDeleteDialogOpen(false)}>
|
||||||
|
<DialogTitle>Typ löschen</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Möchten Sie den Typ "{deletingTyp?.name}" wirklich löschen?
|
||||||
|
Geräte, denen dieser Typ zugeordnet ist, verlieren die Zuordnung.
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteDialogOpen(false)} disabled={deleteMutation.isPending}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => deletingTyp && deleteMutation.mutate(deletingTyp.id)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
startIcon={deleteMutation.isPending ? <CircularProgress size={16} /> : undefined}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AusruestungEinstellungen;
|
||||||
@@ -57,14 +57,15 @@ export default function ChecklistAusfuehrung() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isNew || startingExecution) return;
|
if (!isNew || startingExecution) return;
|
||||||
const fahrzeugId = searchParams.get('fahrzeug');
|
const fahrzeugId = searchParams.get('fahrzeug');
|
||||||
|
const ausruestungId = searchParams.get('ausruestung');
|
||||||
const vorlageId = searchParams.get('vorlage');
|
const vorlageId = searchParams.get('vorlage');
|
||||||
if (!fahrzeugId || !vorlageId) {
|
if (!vorlageId || (!fahrzeugId && !ausruestungId)) {
|
||||||
showError('Fahrzeug und Vorlage sind erforderlich');
|
showError('Vorlage und entweder Fahrzeug oder Ausrüstung sind erforderlich');
|
||||||
navigate('/checklisten');
|
navigate('/checklisten');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStartingExecution(true);
|
setStartingExecution(true);
|
||||||
checklistenApi.startExecution(fahrzeugId, Number(vorlageId))
|
checklistenApi.startExecution(Number(vorlageId), { fahrzeugId: fahrzeugId ?? undefined, ausruestungId: ausruestungId ?? undefined })
|
||||||
.then((exec) => navigate(`/checklisten/ausfuehrung/${exec.id}`, { replace: true }))
|
.then((exec) => navigate(`/checklisten/ausfuehrung/${exec.id}`, { replace: true }))
|
||||||
.catch(() => { showError('Checkliste konnte nicht gestartet werden'); navigate('/checklisten'); });
|
.catch(() => { showError('Checkliste konnte nicht gestartet werden'); navigate('/checklisten'); });
|
||||||
}, [isNew, searchParams, navigate, showError, startingExecution]);
|
}, [isNew, searchParams, navigate, showError, startingExecution]);
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionDetails,
|
||||||
|
AccordionSummary,
|
||||||
|
Alert,
|
||||||
|
Autocomplete,
|
||||||
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
Chip,
|
Chip,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -12,10 +16,16 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
|
FormLabel,
|
||||||
IconButton,
|
IconButton,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Paper,
|
Paper,
|
||||||
|
Radio,
|
||||||
|
RadioGroup,
|
||||||
Select,
|
Select,
|
||||||
Switch,
|
Switch,
|
||||||
Tab,
|
Tab,
|
||||||
@@ -32,12 +42,12 @@ import {
|
|||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Add as AddIcon,
|
Add as AddIcon,
|
||||||
CheckCircle,
|
BuildCircle,
|
||||||
Delete as DeleteIcon,
|
Delete as DeleteIcon,
|
||||||
|
DirectionsCar,
|
||||||
Edit as EditIcon,
|
Edit as EditIcon,
|
||||||
|
ExpandMore,
|
||||||
PlayArrow,
|
PlayArrow,
|
||||||
Schedule,
|
|
||||||
Warning,
|
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
@@ -46,7 +56,9 @@ import { usePermissionContext } from '../contexts/PermissionContext';
|
|||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { checklistenApi } from '../services/checklisten';
|
import { checklistenApi } from '../services/checklisten';
|
||||||
import { fahrzeugTypenApi } from '../services/fahrzeugTypen';
|
import { fahrzeugTypenApi } from '../services/fahrzeugTypen';
|
||||||
|
import { ausruestungTypenApi } from '../services/ausruestungTypen';
|
||||||
import { vehiclesApi } from '../services/vehicles';
|
import { vehiclesApi } from '../services/vehicles';
|
||||||
|
import { equipmentApi } from '../services/equipment';
|
||||||
import {
|
import {
|
||||||
CHECKLIST_STATUS_LABELS,
|
CHECKLIST_STATUS_LABELS,
|
||||||
CHECKLIST_STATUS_COLORS,
|
CHECKLIST_STATUS_COLORS,
|
||||||
@@ -54,12 +66,14 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
ChecklistVorlage,
|
ChecklistVorlage,
|
||||||
ChecklistAusfuehrung,
|
ChecklistAusfuehrung,
|
||||||
ChecklistFaelligkeit,
|
ChecklistOverviewItem,
|
||||||
|
ChecklistOverviewChecklist,
|
||||||
FahrzeugTyp,
|
FahrzeugTyp,
|
||||||
CreateVorlagePayload,
|
CreateVorlagePayload,
|
||||||
UpdateVorlagePayload,
|
UpdateVorlagePayload,
|
||||||
CreateVorlageItemPayload,
|
CreateVorlageItemPayload,
|
||||||
} from '../types/checklist.types';
|
} from '../types/checklist.types';
|
||||||
|
import type { AusruestungTyp } from '../services/ausruestungTypen';
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
||||||
@@ -73,6 +87,40 @@ const INTERVALL_LABELS: Record<string, string> = {
|
|||||||
custom: 'Benutzerdefiniert',
|
custom: 'Benutzerdefiniert',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AssignmentType = 'global' | 'fahrzeug_typ' | 'fahrzeug' | 'ausruestung_typ' | 'ausruestung';
|
||||||
|
|
||||||
|
function getAssignmentType(v: ChecklistVorlage): AssignmentType {
|
||||||
|
if (v.fahrzeug_id) return 'fahrzeug';
|
||||||
|
if (v.fahrzeug_typ_id) return 'fahrzeug_typ';
|
||||||
|
if (v.ausruestung_id) return 'ausruestung';
|
||||||
|
if (v.ausruestung_typ_id) return 'ausruestung_typ';
|
||||||
|
return 'global';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAssignmentLabel(v: ChecklistVorlage): string {
|
||||||
|
if (v.fahrzeug_id) return v.fahrzeug_name ? `Fahrzeug: ${v.fahrzeug_name}` : 'Fahrzeug (direkt)';
|
||||||
|
if (v.fahrzeug_typ_id) return v.fahrzeug_typ?.name ? `Fahrzeugtyp: ${v.fahrzeug_typ.name}` : 'Fahrzeugtyp';
|
||||||
|
if (v.ausruestung_id) return v.ausruestung_name ? `Ausrüstung: ${v.ausruestung_name}` : 'Ausrüstung (direkt)';
|
||||||
|
if (v.ausruestung_typ_id) return v.ausruestung_typ ? `Ausrüstungstyp: ${v.ausruestung_typ}` : 'Ausrüstungstyp';
|
||||||
|
return 'Global';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDueColor(nextDue?: string): 'error' | 'warning' | 'success' {
|
||||||
|
if (!nextDue) return 'success';
|
||||||
|
const daysUntil = Math.ceil((new Date(nextDue).getTime() - Date.now()) / 86400000);
|
||||||
|
if (daysUntil < 0) return 'error';
|
||||||
|
if (daysUntil <= 3) return 'warning';
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDueLabel(nextDue?: string): string {
|
||||||
|
if (!nextDue) return 'Aktuell';
|
||||||
|
const daysUntil = Math.ceil((new Date(nextDue).getTime() - Date.now()) / 86400000);
|
||||||
|
if (daysUntil < 0) return `${Math.abs(daysUntil)}d überfällig`;
|
||||||
|
if (daysUntil === 0) return 'Heute fällig';
|
||||||
|
return `in ${daysUntil}d fällig`;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tab Panel ──
|
// ── Tab Panel ──
|
||||||
|
|
||||||
interface TabPanelProps { children: React.ReactNode; index: number; value: number }
|
interface TabPanelProps { children: React.ReactNode; index: number; value: number }
|
||||||
@@ -95,8 +143,8 @@ export default function Checklisten() {
|
|||||||
const canManageTemplates = hasPermission('checklisten:manage_templates');
|
const canManageTemplates = hasPermission('checklisten:manage_templates');
|
||||||
const canExecute = hasPermission('checklisten:execute');
|
const canExecute = hasPermission('checklisten:execute');
|
||||||
|
|
||||||
// Tabs: 0=Übersicht, 1=Vorlagen (if perm), 2=Fahrzeugtypen (if perm), 3=Historie
|
// Tabs: 0=Übersicht, 1=Vorlagen (if perm), 2=Historie
|
||||||
const manageTabs = canManageTemplates ? 2 : 0;
|
const manageTabs = canManageTemplates ? 1 : 0;
|
||||||
const TAB_COUNT = 2 + manageTabs;
|
const TAB_COUNT = 2 + manageTabs;
|
||||||
|
|
||||||
const [tab, setTab] = useState(() => {
|
const [tab, setTab] = useState(() => {
|
||||||
@@ -109,15 +157,10 @@ export default function Checklisten() {
|
|||||||
}, [searchParams, TAB_COUNT]);
|
}, [searchParams, TAB_COUNT]);
|
||||||
|
|
||||||
// ── Queries ──
|
// ── Queries ──
|
||||||
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
|
const { data: overview, isLoading: overviewLoading } = useQuery({
|
||||||
queryKey: ['vehicles', 'checklisten-overview'],
|
queryKey: ['checklisten', 'overview'],
|
||||||
queryFn: () => vehiclesApi.getAll(),
|
queryFn: checklistenApi.getOverview,
|
||||||
});
|
refetchInterval: 60000,
|
||||||
|
|
||||||
const { data: overdue = [] } = useQuery({
|
|
||||||
queryKey: ['checklisten-faellig'],
|
|
||||||
queryFn: checklistenApi.getOverdue,
|
|
||||||
refetchInterval: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: vorlagen = [], isLoading: vorlagenLoading } = useQuery({
|
const { data: vorlagen = [], isLoading: vorlagenLoading } = useQuery({
|
||||||
@@ -137,17 +180,9 @@ export default function Checklisten() {
|
|||||||
queryFn: () => checklistenApi.getExecutions(),
|
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 ──
|
// ── Tab indices ──
|
||||||
const vorlagenTabIdx = canManageTemplates ? 1 : -1;
|
const vorlagenTabIdx = canManageTemplates ? 1 : -1;
|
||||||
const typenTabIdx = canManageTemplates ? 2 : -1;
|
const historieTabIdx = canManageTemplates ? 2 : 1;
|
||||||
const historieTabIdx = canManageTemplates ? 3 : 1;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
@@ -162,73 +197,18 @@ export default function Checklisten() {
|
|||||||
>
|
>
|
||||||
<Tab label="Übersicht" />
|
<Tab label="Übersicht" />
|
||||||
{canManageTemplates && <Tab label="Vorlagen" />}
|
{canManageTemplates && <Tab label="Vorlagen" />}
|
||||||
{canManageTemplates && <Tab label="Fahrzeugtypen" />}
|
|
||||||
<Tab label="Historie" />
|
<Tab label="Historie" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Tab 0: Übersicht */}
|
{/* Tab 0: Übersicht */}
|
||||||
<TabPanel value={tab} index={0}>
|
<TabPanel value={tab} index={0}>
|
||||||
{vehiclesLoading ? (
|
<OverviewTab
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
|
overview={overview}
|
||||||
) : vehicles.length === 0 ? (
|
loading={overviewLoading}
|
||||||
<Typography color="text.secondary">Keine Fahrzeuge vorhanden.</Typography>
|
canExecute={canExecute}
|
||||||
) : (
|
navigate={navigate}
|
||||||
<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ällig`}
|
|
||||||
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 überfällig` : 'heute fällig'})
|
|
||||||
</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>
|
</TabPanel>
|
||||||
|
|
||||||
{/* Tab 1: Vorlagen (templates) */}
|
{/* Tab 1: Vorlagen (templates) */}
|
||||||
@@ -245,19 +225,7 @@ export default function Checklisten() {
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tab 2: Fahrzeugtypen */}
|
{/* Tab 2: Historie */}
|
||||||
{canManageTemplates && (
|
|
||||||
<TabPanel value={tab} index={typenTabIdx}>
|
|
||||||
<FahrzeugTypenTab
|
|
||||||
fahrzeugTypen={fahrzeugTypen}
|
|
||||||
queryClient={queryClient}
|
|
||||||
showSuccess={showSuccess}
|
|
||||||
showError={showError}
|
|
||||||
/>
|
|
||||||
</TabPanel>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tab 3: Historie */}
|
|
||||||
<TabPanel value={tab} index={historieTabIdx}>
|
<TabPanel value={tab} index={historieTabIdx}>
|
||||||
<HistorieTab
|
<HistorieTab
|
||||||
executions={executions}
|
executions={executions}
|
||||||
@@ -269,6 +237,113 @@ export default function Checklisten() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Overview Tab
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
interface OverviewTabProps {
|
||||||
|
overview?: { vehicles: ChecklistOverviewItem[]; equipment: ChecklistOverviewItem[] };
|
||||||
|
loading: boolean;
|
||||||
|
canExecute: boolean;
|
||||||
|
navigate: ReturnType<typeof useNavigate>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewTab({ overview, loading, canExecute, navigate }: OverviewTabProps) {
|
||||||
|
if (loading) {
|
||||||
|
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vehicles = overview?.vehicles ?? [];
|
||||||
|
const equipment = overview?.equipment ?? [];
|
||||||
|
|
||||||
|
if (vehicles.length === 0 && equipment.length === 0) {
|
||||||
|
return <Alert severity="success" sx={{ mt: 1 }}>Keine offenen oder fälligen Checks</Alert>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderChecklistRow = (cl: ChecklistOverviewChecklist, itemId: string, type: 'fahrzeug' | 'ausruestung') => {
|
||||||
|
const color = getDueColor(cl.next_due);
|
||||||
|
const label = getDueLabel(cl.next_due);
|
||||||
|
const param = type === 'fahrzeug' ? `fahrzeug=${itemId}` : `ausruestung=${itemId}`;
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
key={cl.vorlage_id}
|
||||||
|
sx={{ py: 0.5, px: 1 }}
|
||||||
|
secondaryAction={
|
||||||
|
canExecute ? (
|
||||||
|
<Tooltip title="Checkliste starten">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => navigate(`/checklisten/ausfuehrung/new?${param}&vorlage=${cl.vorlage_id}`)}
|
||||||
|
>
|
||||||
|
<PlayArrow fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={cl.vorlage_name}
|
||||||
|
primaryTypographyProps={{ variant: 'body2' }}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={label}
|
||||||
|
color={color}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ mr: canExecute ? 4 : 0, ml: 1 }}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSection = (
|
||||||
|
title: string,
|
||||||
|
icon: React.ReactNode,
|
||||||
|
items: ChecklistOverviewItem[],
|
||||||
|
type: 'fahrzeug' | 'ausruestung',
|
||||||
|
) => {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 1.5, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{icon} {title}
|
||||||
|
</Typography>
|
||||||
|
{items.map((item) => {
|
||||||
|
const totalDue = item.checklists.length;
|
||||||
|
const hasOverdue = item.checklists.some((cl) => getDueColor(cl.next_due) === 'error');
|
||||||
|
return (
|
||||||
|
<Accordion key={item.id} variant="outlined" disableGutters>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, width: '100%' }}>
|
||||||
|
<Typography variant="subtitle1">{item.name}</Typography>
|
||||||
|
<Badge
|
||||||
|
badgeContent={totalDue}
|
||||||
|
color={hasOverdue ? 'error' : 'warning'}
|
||||||
|
sx={{ ml: 'auto', mr: 2 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails sx={{ p: 0 }}>
|
||||||
|
<List dense disablePadding>
|
||||||
|
{item.checklists.map((cl) => renderChecklistRow(cl, item.id, type))}
|
||||||
|
</List>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{renderSection('Fahrzeuge', <DirectionsCar color="action" />, vehicles, 'fahrzeug')}
|
||||||
|
{renderSection('Ausrüstung', <BuildCircle color="action" />, equipment, 'ausruestung')}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
// Vorlagen Tab
|
// Vorlagen Tab
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -287,9 +362,33 @@ function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSucces
|
|||||||
const [editingVorlage, setEditingVorlage] = useState<ChecklistVorlage | null>(null);
|
const [editingVorlage, setEditingVorlage] = useState<ChecklistVorlage | null>(null);
|
||||||
const [expandedVorlageId, setExpandedVorlageId] = useState<number | 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 };
|
// Assignment form state
|
||||||
|
const [assignmentType, setAssignmentType] = useState<AssignmentType>('global');
|
||||||
|
|
||||||
|
const emptyForm: CreateVorlagePayload = {
|
||||||
|
name: '', fahrzeug_typ_id: undefined, fahrzeug_id: undefined,
|
||||||
|
ausruestung_typ_id: undefined, ausruestung_id: undefined,
|
||||||
|
intervall: undefined, intervall_tage: undefined, beschreibung: '', aktiv: true,
|
||||||
|
};
|
||||||
const [form, setForm] = useState<CreateVorlagePayload>(emptyForm);
|
const [form, setForm] = useState<CreateVorlagePayload>(emptyForm);
|
||||||
|
|
||||||
|
// Fetch vehicle / equipment lists for the assignment pickers
|
||||||
|
const { data: vehiclesList = [] } = useQuery({
|
||||||
|
queryKey: ['vehicles', 'list'],
|
||||||
|
queryFn: () => vehiclesApi.getAll(),
|
||||||
|
enabled: dialogOpen && assignmentType === 'fahrzeug',
|
||||||
|
});
|
||||||
|
const { data: equipmentList = [] } = useQuery({
|
||||||
|
queryKey: ['equipment', 'list'],
|
||||||
|
queryFn: () => equipmentApi.getAll(),
|
||||||
|
enabled: dialogOpen && assignmentType === 'ausruestung',
|
||||||
|
});
|
||||||
|
const { data: ausruestungTypen = [] } = useQuery({
|
||||||
|
queryKey: ['ausruestung-typen'],
|
||||||
|
queryFn: ausruestungTypenApi.getAll,
|
||||||
|
enabled: dialogOpen && assignmentType === 'ausruestung_typ',
|
||||||
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: CreateVorlagePayload) => checklistenApi.createVorlage(data),
|
mutationFn: (data: CreateVorlagePayload) => checklistenApi.createVorlage(data),
|
||||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['checklisten-vorlagen'] }); setDialogOpen(false); showSuccess('Vorlage erstellt'); },
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['checklisten-vorlagen'] }); setDialogOpen(false); showSuccess('Vorlage erstellt'); },
|
||||||
@@ -308,22 +407,67 @@ function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSucces
|
|||||||
onError: () => showError('Fehler beim Löschen der Vorlage'),
|
onError: () => showError('Fehler beim Löschen der Vorlage'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const openCreate = () => { setEditingVorlage(null); setForm(emptyForm); setDialogOpen(true); };
|
const openCreate = () => {
|
||||||
|
setEditingVorlage(null);
|
||||||
|
setForm(emptyForm);
|
||||||
|
setAssignmentType('global');
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
const openEdit = (v: ChecklistVorlage) => {
|
const openEdit = (v: ChecklistVorlage) => {
|
||||||
setEditingVorlage(v);
|
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 });
|
setAssignmentType(getAssignmentType(v));
|
||||||
|
setForm({
|
||||||
|
name: v.name,
|
||||||
|
fahrzeug_typ_id: v.fahrzeug_typ_id,
|
||||||
|
fahrzeug_id: v.fahrzeug_id,
|
||||||
|
ausruestung_typ_id: v.ausruestung_typ_id,
|
||||||
|
ausruestung_id: v.ausruestung_id,
|
||||||
|
intervall: v.intervall,
|
||||||
|
intervall_tage: v.intervall_tage,
|
||||||
|
beschreibung: v.beschreibung ?? '',
|
||||||
|
aktiv: v.aktiv,
|
||||||
|
});
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildPayload = (): CreateVorlagePayload => {
|
||||||
|
const base: CreateVorlagePayload = {
|
||||||
|
name: form.name,
|
||||||
|
intervall: form.intervall,
|
||||||
|
intervall_tage: form.intervall_tage,
|
||||||
|
beschreibung: form.beschreibung,
|
||||||
|
aktiv: form.aktiv,
|
||||||
|
};
|
||||||
|
switch (assignmentType) {
|
||||||
|
case 'fahrzeug_typ': return { ...base, fahrzeug_typ_id: form.fahrzeug_typ_id };
|
||||||
|
case 'fahrzeug': return { ...base, fahrzeug_id: form.fahrzeug_id };
|
||||||
|
case 'ausruestung_typ': return { ...base, ausruestung_typ_id: form.ausruestung_typ_id };
|
||||||
|
case 'ausruestung': return { ...base, ausruestung_id: form.ausruestung_id };
|
||||||
|
default: return base;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!form.name.trim()) return;
|
if (!form.name.trim()) return;
|
||||||
|
const payload = buildPayload();
|
||||||
if (editingVorlage) {
|
if (editingVorlage) {
|
||||||
updateMutation.mutate({ id: editingVorlage.id, data: form });
|
updateMutation.mutate({ id: editingVorlage.id, data: payload });
|
||||||
} else {
|
} else {
|
||||||
createMutation.mutate(form);
|
createMutation.mutate(payload);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAssignmentTypeChange = (newType: AssignmentType) => {
|
||||||
|
setAssignmentType(newType);
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
fahrzeug_typ_id: undefined,
|
||||||
|
fahrzeug_id: undefined,
|
||||||
|
ausruestung_typ_id: undefined,
|
||||||
|
ausruestung_id: undefined,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
|
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
|
||||||
@@ -339,7 +483,7 @@ function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSucces
|
|||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Name</TableCell>
|
<TableCell>Name</TableCell>
|
||||||
<TableCell>Fahrzeugtyp</TableCell>
|
<TableCell>Zuordnung</TableCell>
|
||||||
<TableCell>Intervall</TableCell>
|
<TableCell>Intervall</TableCell>
|
||||||
<TableCell>Aktiv</TableCell>
|
<TableCell>Aktiv</TableCell>
|
||||||
<TableCell align="right">Aktionen</TableCell>
|
<TableCell align="right">Aktionen</TableCell>
|
||||||
@@ -353,7 +497,7 @@ function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSucces
|
|||||||
<React.Fragment key={v.id}>
|
<React.Fragment key={v.id}>
|
||||||
<TableRow hover sx={{ cursor: 'pointer' }} onClick={() => setExpandedVorlageId(expandedVorlageId === v.id ? null : v.id)}>
|
<TableRow hover sx={{ cursor: 'pointer' }} onClick={() => setExpandedVorlageId(expandedVorlageId === v.id ? null : v.id)}>
|
||||||
<TableCell>{v.name}</TableCell>
|
<TableCell>{v.name}</TableCell>
|
||||||
<TableCell>{v.fahrzeug_typ?.name ?? '–'}</TableCell>
|
<TableCell>{getAssignmentLabel(v)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{v.intervall ? INTERVALL_LABELS[v.intervall] || v.intervall : '–'}
|
{v.intervall ? INTERVALL_LABELS[v.intervall] || v.intervall : '–'}
|
||||||
{v.intervall === 'custom' && v.intervall_tage ? ` (${v.intervall_tage} Tage)` : ''}
|
{v.intervall === 'custom' && v.intervall_tage ? ` (${v.intervall_tage} Tage)` : ''}
|
||||||
@@ -385,13 +529,64 @@ function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSucces
|
|||||||
<DialogTitle>{editingVorlage ? 'Vorlage bearbeiten' : 'Neue Vorlage'}</DialogTitle>
|
<DialogTitle>{editingVorlage ? 'Vorlage bearbeiten' : 'Neue Vorlage'}</DialogTitle>
|
||||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
<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="Name *" fullWidth value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
|
||||||
<FormControl fullWidth>
|
|
||||||
<InputLabel>Fahrzeugtyp</InputLabel>
|
{/* Assignment type */}
|
||||||
<Select label="Fahrzeugtyp" value={form.fahrzeug_typ_id ?? ''} onChange={(e) => setForm((f) => ({ ...f, fahrzeug_typ_id: e.target.value ? Number(e.target.value) : undefined }))}>
|
<FormControl>
|
||||||
<MenuItem value="">Alle (global)</MenuItem>
|
<FormLabel>Zuordnung</FormLabel>
|
||||||
{fahrzeugTypen.map((t) => <MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>)}
|
<RadioGroup
|
||||||
</Select>
|
row
|
||||||
|
value={assignmentType}
|
||||||
|
onChange={(e) => handleAssignmentTypeChange(e.target.value as AssignmentType)}
|
||||||
|
>
|
||||||
|
<FormControlLabel value="global" control={<Radio size="small" />} label="Global" />
|
||||||
|
<FormControlLabel value="fahrzeug_typ" control={<Radio size="small" />} label="Fahrzeugtyp" />
|
||||||
|
<FormControlLabel value="fahrzeug" control={<Radio size="small" />} label="Fahrzeug" />
|
||||||
|
<FormControlLabel value="ausruestung_typ" control={<Radio size="small" />} label="Ausrüstungstyp" />
|
||||||
|
<FormControlLabel value="ausruestung" control={<Radio size="small" />} label="Ausrüstung" />
|
||||||
|
</RadioGroup>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
{/* Assignment picker based on type */}
|
||||||
|
{assignmentType === 'fahrzeug_typ' && (
|
||||||
|
<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 }))}
|
||||||
|
>
|
||||||
|
{fahrzeugTypen.map((t) => <MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
{assignmentType === 'fahrzeug' && (
|
||||||
|
<Autocomplete
|
||||||
|
options={vehiclesList}
|
||||||
|
getOptionLabel={(v) => v.bezeichnung ?? v.kurzname ?? String(v.id)}
|
||||||
|
value={vehiclesList.find((v) => v.id === form.fahrzeug_id) ?? null}
|
||||||
|
onChange={(_e, v) => setForm((f) => ({ ...f, fahrzeug_id: v?.id }))}
|
||||||
|
renderInput={(params) => <TextField {...params} label="Fahrzeug" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{assignmentType === 'ausruestung_typ' && (
|
||||||
|
<Autocomplete
|
||||||
|
options={ausruestungTypen}
|
||||||
|
getOptionLabel={(t: AusruestungTyp) => t.name}
|
||||||
|
value={ausruestungTypen.find((t: AusruestungTyp) => t.id === form.ausruestung_typ_id) ?? null}
|
||||||
|
onChange={(_e, t: AusruestungTyp | null) => setForm((f) => ({ ...f, ausruestung_typ_id: t?.id }))}
|
||||||
|
renderInput={(params) => <TextField {...params} label="Ausrüstungstyp" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{assignmentType === 'ausruestung' && (
|
||||||
|
<Autocomplete
|
||||||
|
options={equipmentList}
|
||||||
|
getOptionLabel={(eq) => eq.bezeichnung ?? String(eq.id)}
|
||||||
|
value={equipmentList.find((eq) => eq.id === form.ausruestung_id) ?? null}
|
||||||
|
onChange={(_e, eq) => setForm((f) => ({ ...f, ausruestung_id: eq?.id }))}
|
||||||
|
renderInput={(params) => <TextField {...params} label="Ausrüstung" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<InputLabel>Intervall</InputLabel>
|
<InputLabel>Intervall</InputLabel>
|
||||||
<Select label="Intervall" value={form.intervall ?? ''} onChange={(e) => setForm((f) => ({ ...f, intervall: (e.target.value || undefined) as CreateVorlagePayload['intervall'] }))}>
|
<Select label="Intervall" value={form.intervall ?? ''} onChange={(e) => setForm((f) => ({ ...f, intervall: (e.target.value || undefined) as CreateVorlagePayload['intervall'] }))}>
|
||||||
@@ -465,108 +660,6 @@ function VorlageItemsSection({ vorlageId, queryClient, showSuccess, showError }:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
// 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öscht'); },
|
|
||||||
onError: () => showError('Fehler beim Löschen'),
|
|
||||||
});
|
|
||||||
|
|
||||||
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 ?? '–'}</TableCell>
|
|
||||||
<TableCell>{t.icon ?? '–'}</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
|
// Historie Tab
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -579,15 +672,18 @@ interface HistorieTabProps {
|
|||||||
|
|
||||||
function HistorieTab({ executions, loading, navigate }: HistorieTabProps) {
|
function HistorieTab({ executions, loading, navigate }: HistorieTabProps) {
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||||
const [vehicleFilter, setVehicleFilter] = useState<string>('');
|
const [targetFilter, setTargetFilter] = useState<string>('');
|
||||||
|
|
||||||
const filtered = executions.filter((e) => {
|
const filtered = executions.filter((e) => {
|
||||||
if (statusFilter && e.status !== statusFilter) return false;
|
if (statusFilter && e.status !== statusFilter) return false;
|
||||||
if (vehicleFilter && e.fahrzeug_name !== vehicleFilter) return false;
|
const targetName = e.fahrzeug_name || e.ausruestung_name || '';
|
||||||
|
if (targetFilter && targetName !== targetFilter) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const uniqueVehicles = [...new Set(executions.map((e) => e.fahrzeug_name).filter(Boolean))] as string[];
|
const uniqueTargets = [...new Set(
|
||||||
|
executions.map((e) => e.fahrzeug_name || e.ausruestung_name).filter(Boolean)
|
||||||
|
)] as string[];
|
||||||
|
|
||||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
|
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
|
||||||
|
|
||||||
@@ -603,11 +699,11 @@ function HistorieTab({ executions, loading, navigate }: HistorieTabProps) {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||||
<InputLabel>Fahrzeug</InputLabel>
|
<InputLabel>Fahrzeug / Ausrüstung</InputLabel>
|
||||||
<Select label="Fahrzeug" value={vehicleFilter} onChange={(e) => setVehicleFilter(e.target.value)}>
|
<Select label="Fahrzeug / Ausrüstung" value={targetFilter} onChange={(e) => setTargetFilter(e.target.value)}>
|
||||||
<MenuItem value="">Alle</MenuItem>
|
<MenuItem value="">Alle</MenuItem>
|
||||||
{uniqueVehicles.map((v) => <MenuItem key={v} value={v}>{v}</MenuItem>)}
|
{uniqueTargets.map((v) => <MenuItem key={v} value={v}>{v}</MenuItem>)}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -616,7 +712,7 @@ function HistorieTab({ executions, loading, navigate }: HistorieTabProps) {
|
|||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Fahrzeug</TableCell>
|
<TableCell>Fahrzeug / Ausrüstung</TableCell>
|
||||||
<TableCell>Vorlage</TableCell>
|
<TableCell>Vorlage</TableCell>
|
||||||
<TableCell>Datum</TableCell>
|
<TableCell>Datum</TableCell>
|
||||||
<TableCell>Status</TableCell>
|
<TableCell>Status</TableCell>
|
||||||
@@ -630,7 +726,7 @@ function HistorieTab({ executions, loading, navigate }: HistorieTabProps) {
|
|||||||
) : (
|
) : (
|
||||||
filtered.map((e) => (
|
filtered.map((e) => (
|
||||||
<TableRow key={e.id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/checklisten/ausfuehrung/${e.id}`)}>
|
<TableRow key={e.id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/checklisten/ausfuehrung/${e.id}`)}>
|
||||||
<TableCell>{e.fahrzeug_name ?? '–'}</TableCell>
|
<TableCell>{e.fahrzeug_name || e.ausruestung_name || '–'}</TableCell>
|
||||||
<TableCell>{e.vorlage_name ?? '–'}</TableCell>
|
<TableCell>{e.vorlage_name ?? '–'}</TableCell>
|
||||||
<TableCell>{formatDate(e.ausgefuehrt_am ?? e.created_at)}</TableCell>
|
<TableCell>{formatDate(e.ausgefuehrt_am ?? e.created_at)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Autocomplete,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
@@ -60,6 +61,8 @@ import { vehiclesApi } from '../services/vehicles';
|
|||||||
import GermanDateField from '../components/shared/GermanDateField';
|
import GermanDateField from '../components/shared/GermanDateField';
|
||||||
import { fromGermanDate } from '../utils/dateInput';
|
import { fromGermanDate } from '../utils/dateInput';
|
||||||
import { equipmentApi } from '../services/equipment';
|
import { equipmentApi } from '../services/equipment';
|
||||||
|
import { fahrzeugTypenApi } from '../services/fahrzeugTypen';
|
||||||
|
import type { FahrzeugTyp } from '../types/checklist.types';
|
||||||
import {
|
import {
|
||||||
FahrzeugDetail as FahrzeugDetailType,
|
FahrzeugDetail as FahrzeugDetailType,
|
||||||
FahrzeugWartungslog,
|
FahrzeugWartungslog,
|
||||||
@@ -187,9 +190,10 @@ interface UebersichtTabProps {
|
|||||||
vehicle: FahrzeugDetailType;
|
vehicle: FahrzeugDetailType;
|
||||||
onStatusUpdated: () => void;
|
onStatusUpdated: () => void;
|
||||||
canChangeStatus: boolean;
|
canChangeStatus: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated, canChangeStatus }) => {
|
const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated, canChangeStatus, canEdit }) => {
|
||||||
const [statusDialogOpen, setStatusDialogOpen] = useState(false);
|
const [statusDialogOpen, setStatusDialogOpen] = useState(false);
|
||||||
const [newStatus, setNewStatus] = useState<FahrzeugStatus>(vehicle.status);
|
const [newStatus, setNewStatus] = useState<FahrzeugStatus>(vehicle.status);
|
||||||
const [bemerkung, setBemerkung] = useState(vehicle.status_bemerkung ?? '');
|
const [bemerkung, setBemerkung] = useState(vehicle.status_bemerkung ?? '');
|
||||||
@@ -203,6 +207,43 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
|||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const [overlappingBookings, setOverlappingBookings] = useState<OverlappingBooking[]>([]);
|
const [overlappingBookings, setOverlappingBookings] = useState<OverlappingBooking[]>([]);
|
||||||
|
|
||||||
|
// ── Fahrzeugtypen ──
|
||||||
|
const [allTypes, setAllTypes] = useState<FahrzeugTyp[]>([]);
|
||||||
|
const [vehicleTypes, setVehicleTypes] = useState<FahrzeugTyp[]>([]);
|
||||||
|
const [typesLoading, setTypesLoading] = useState(true);
|
||||||
|
const [editingTypes, setEditingTypes] = useState(false);
|
||||||
|
const [selectedTypes, setSelectedTypes] = useState<FahrzeugTyp[]>([]);
|
||||||
|
const [typesSaving, setTypesSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
Promise.all([
|
||||||
|
fahrzeugTypenApi.getAll(),
|
||||||
|
fahrzeugTypenApi.getTypesForVehicle(vehicle.id),
|
||||||
|
])
|
||||||
|
.then(([all, assigned]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setAllTypes(all);
|
||||||
|
setVehicleTypes(assigned);
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => { if (!cancelled) setTypesLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [vehicle.id]);
|
||||||
|
|
||||||
|
const handleSaveTypes = async () => {
|
||||||
|
try {
|
||||||
|
setTypesSaving(true);
|
||||||
|
await fahrzeugTypenApi.setTypesForVehicle(vehicle.id, selectedTypes.map((t) => t.id));
|
||||||
|
setVehicleTypes(selectedTypes);
|
||||||
|
setEditingTypes(false);
|
||||||
|
} catch {
|
||||||
|
// silent — keep dialog open
|
||||||
|
} finally {
|
||||||
|
setTypesSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isAusserDienst = (s: FahrzeugStatus) =>
|
const isAusserDienst = (s: FahrzeugStatus) =>
|
||||||
s === FahrzeugStatus.AusserDienstWartung || s === FahrzeugStatus.AusserDienstSchaden;
|
s === FahrzeugStatus.AusserDienstWartung || s === FahrzeugStatus.AusserDienstSchaden;
|
||||||
|
|
||||||
@@ -342,6 +383,50 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
|||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* Fahrzeugtypen */}
|
||||||
|
<Typography variant="h6" sx={{ mt: 3, mb: 1.5 }}>
|
||||||
|
Fahrzeugtypen
|
||||||
|
</Typography>
|
||||||
|
{typesLoading ? (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
) : editingTypes ? (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
options={allTypes}
|
||||||
|
getOptionLabel={(o) => o.name}
|
||||||
|
value={selectedTypes}
|
||||||
|
onChange={(_e, val) => setSelectedTypes(val)}
|
||||||
|
isOptionEqualToValue={(a, b) => a.id === b.id}
|
||||||
|
renderInput={(params) => <TextField {...params} label="Fahrzeugtypen" size="small" />}
|
||||||
|
sx={{ minWidth: 300, flexGrow: 1 }}
|
||||||
|
/>
|
||||||
|
<Button variant="contained" size="small" onClick={handleSaveTypes} disabled={typesSaving}>
|
||||||
|
{typesSaving ? <CircularProgress size={16} /> : 'Speichern'}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={() => setEditingTypes(false)}>Abbrechen</Button>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
{vehicleTypes.length === 0 ? (
|
||||||
|
<Typography variant="body2" color="text.disabled">Keine Typen zugewiesen</Typography>
|
||||||
|
) : (
|
||||||
|
vehicleTypes.map((t) => (
|
||||||
|
<Chip key={t.id} label={t.name} size="small" variant="outlined" />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => { setSelectedTypes(vehicleTypes); setEditingTypes(true); }}
|
||||||
|
aria-label="Fahrzeugtypen bearbeiten"
|
||||||
|
>
|
||||||
|
<Edit fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Inspection deadline quick view */}
|
{/* Inspection deadline quick view */}
|
||||||
<Typography variant="h6" sx={{ mt: 3, mb: 1.5 }}>
|
<Typography variant="h6" sx={{ mt: 3, mb: 1.5 }}>
|
||||||
Prüf- und Wartungsfristen
|
Prüf- und Wartungsfristen
|
||||||
@@ -1047,6 +1132,7 @@ function FahrzeugDetail() {
|
|||||||
vehicle={vehicle}
|
vehicle={vehicle}
|
||||||
onStatusUpdated={fetchVehicle}
|
onStatusUpdated={fetchVehicle}
|
||||||
canChangeStatus={canChangeStatus}
|
canChangeStatus={canChangeStatus}
|
||||||
|
canEdit={hasPermission('fahrzeuge:edit')}
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
|
|||||||
235
frontend/src/pages/FahrzeugEinstellungen.tsx
Normal file
235
frontend/src/pages/FahrzeugEinstellungen.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Add as AddIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
Settings,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
|
import { fahrzeugTypenApi } from '../services/fahrzeugTypen';
|
||||||
|
import type { FahrzeugTyp } from '../types/checklist.types';
|
||||||
|
|
||||||
|
export default function FahrzeugEinstellungen() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { hasPermission } = usePermissionContext();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
|
||||||
|
const canEdit = hasPermission('fahrzeuge:edit');
|
||||||
|
|
||||||
|
const { data: fahrzeugTypen = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['fahrzeug-typen'],
|
||||||
|
queryFn: fahrzeugTypenApi.getAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<FahrzeugTyp | null>(null);
|
||||||
|
const [form, setForm] = useState({ name: '', beschreibung: '', icon: '' });
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
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'] });
|
||||||
|
setDeleteError(null);
|
||||||
|
showSuccess('Fahrzeugtyp gelöscht');
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
const msg = err?.response?.data?.message || 'Fehler beim Löschen — Typ ist möglicherweise noch in Verwendung.';
|
||||||
|
setDeleteError(msg);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setForm({ name: '', beschreibung: '', icon: '' });
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (t: FahrzeugTyp) => {
|
||||||
|
setEditing(t);
|
||||||
|
setForm({ name: t.name, beschreibung: t.beschreibung ?? '', icon: t.icon ?? '' });
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!form.name.trim()) return;
|
||||||
|
if (editing) {
|
||||||
|
updateMutation.mutate({ id: editing.id, data: form });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(form);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
if (!canEdit) {
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Container maxWidth="lg">
|
||||||
|
<Alert severity="error">Keine Berechtigung für diese Seite.</Alert>
|
||||||
|
</Container>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Container maxWidth="lg">
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 3 }}>
|
||||||
|
<Settings color="action" />
|
||||||
|
<Typography variant="h4" component="h1">
|
||||||
|
Fahrzeug-Einstellungen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||||
|
Fahrzeugtypen
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{deleteError && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setDeleteError(null)}>
|
||||||
|
{deleteError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</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 ?? '–'}</TableCell>
|
||||||
|
<TableCell>{t.icon ?? '–'}</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>
|
||||||
|
</Container>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
frontend/src/services/ausruestungTypen.ts
Normal file
37
frontend/src/services/ausruestungTypen.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
export interface AusruestungTyp {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
beschreibung?: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ausruestungTypenApi = {
|
||||||
|
getAll: async (): Promise<AusruestungTyp[]> => {
|
||||||
|
const r = await api.get('/api/ausruestung-typen');
|
||||||
|
return r.data.data ?? r.data;
|
||||||
|
},
|
||||||
|
getById: async (id: number): Promise<AusruestungTyp> => {
|
||||||
|
const r = await api.get(`/api/ausruestung-typen/${id}`);
|
||||||
|
return r.data.data ?? r.data;
|
||||||
|
},
|
||||||
|
create: async (data: { name: string; beschreibung?: string; icon?: string }): Promise<AusruestungTyp> => {
|
||||||
|
const r = await api.post('/api/ausruestung-typen', data);
|
||||||
|
return r.data.data ?? r.data;
|
||||||
|
},
|
||||||
|
update: async (id: number, data: Partial<{ name: string; beschreibung: string; icon: string }>): Promise<AusruestungTyp> => {
|
||||||
|
const r = await api.patch(`/api/ausruestung-typen/${id}`, data);
|
||||||
|
return r.data.data ?? r.data;
|
||||||
|
},
|
||||||
|
delete: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/api/ausruestung-typen/${id}`);
|
||||||
|
},
|
||||||
|
getTypesForEquipment: async (ausruestungId: string): Promise<AusruestungTyp[]> => {
|
||||||
|
const r = await api.get(`/api/ausruestung-typen/equipment/${ausruestungId}`);
|
||||||
|
return r.data.data ?? r.data;
|
||||||
|
},
|
||||||
|
setTypesForEquipment: async (ausruestungId: string, typIds: number[]): Promise<void> => {
|
||||||
|
await api.put(`/api/ausruestung-typen/equipment/${ausruestungId}`, { typIds });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
FahrzeugChecklistItem,
|
FahrzeugChecklistItem,
|
||||||
ChecklistAusfuehrung,
|
ChecklistAusfuehrung,
|
||||||
ChecklistFaelligkeit,
|
ChecklistFaelligkeit,
|
||||||
|
ChecklistOverviewResponse,
|
||||||
ChecklistVorlageFilter,
|
ChecklistVorlageFilter,
|
||||||
ChecklistAusfuehrungFilter,
|
ChecklistAusfuehrungFilter,
|
||||||
CreateVorlagePayload,
|
CreateVorlagePayload,
|
||||||
@@ -17,6 +18,12 @@ import type {
|
|||||||
} from '../types/checklist.types';
|
} from '../types/checklist.types';
|
||||||
|
|
||||||
export const checklistenApi = {
|
export const checklistenApi = {
|
||||||
|
// ── Overview ──
|
||||||
|
getOverview: async (): Promise<ChecklistOverviewResponse> => {
|
||||||
|
const r = await api.get('/api/checklisten/overview');
|
||||||
|
return r.data.data ?? r.data;
|
||||||
|
},
|
||||||
|
|
||||||
// ── Vorlagen (Templates) ──
|
// ── Vorlagen (Templates) ──
|
||||||
getVorlagen: async (filter?: ChecklistVorlageFilter): Promise<ChecklistVorlage[]> => {
|
getVorlagen: async (filter?: ChecklistVorlageFilter): Promise<ChecklistVorlage[]> => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -86,6 +93,31 @@ export const checklistenApi = {
|
|||||||
await api.delete(`/api/checklisten/fahrzeug-items/${id}`);
|
await api.delete(`/api/checklisten/fahrzeug-items/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Equipment-specific Items ──
|
||||||
|
getTemplatesForEquipment: async (ausruestungId: string): Promise<ChecklistVorlage[]> => {
|
||||||
|
const r = await api.get(`/api/checklisten/equipment/${ausruestungId}/vorlagen`);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getEquipmentItems: async (ausruestungId: string): Promise<FahrzeugChecklistItem[]> => {
|
||||||
|
const r = await api.get(`/api/checklisten/equipment/${ausruestungId}/items`);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
addEquipmentItem: async (ausruestungId: string, data: CreateFahrzeugItemPayload): Promise<FahrzeugChecklistItem> => {
|
||||||
|
const r = await api.post(`/api/checklisten/equipment/${ausruestungId}/items`, data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateEquipmentItem: async (ausruestungId: string, itemId: number, data: UpdateFahrzeugItemPayload): Promise<FahrzeugChecklistItem> => {
|
||||||
|
const r = await api.patch(`/api/checklisten/equipment/${ausruestungId}/items/${itemId}`, data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteEquipmentItem: async (ausruestungId: string, itemId: number): Promise<void> => {
|
||||||
|
await api.delete(`/api/checklisten/equipment/${ausruestungId}/items/${itemId}`);
|
||||||
|
},
|
||||||
|
|
||||||
// ── Checklists for a Vehicle ──
|
// ── Checklists for a Vehicle ──
|
||||||
getChecklistenForVehicle: async (fahrzeugId: string): Promise<ChecklistVorlage[]> => {
|
getChecklistenForVehicle: async (fahrzeugId: string): Promise<ChecklistVorlage[]> => {
|
||||||
const r = await api.get(`/api/checklisten/fahrzeug/${fahrzeugId}/checklisten`);
|
const r = await api.get(`/api/checklisten/fahrzeug/${fahrzeugId}/checklisten`);
|
||||||
@@ -93,8 +125,12 @@ export const checklistenApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Executions ──
|
// ── Executions ──
|
||||||
startExecution: async (fahrzeugId: string, vorlageId: number): Promise<ChecklistAusfuehrung> => {
|
startExecution: async (vorlageId: number, opts: { fahrzeugId?: string; ausruestungId?: string }): Promise<ChecklistAusfuehrung> => {
|
||||||
const r = await api.post('/api/checklisten/ausfuehrungen', { fahrzeug_id: fahrzeugId, vorlage_id: vorlageId });
|
const r = await api.post('/api/checklisten/ausfuehrungen', {
|
||||||
|
vorlage_id: vorlageId,
|
||||||
|
fahrzeugId: opts.fahrzeugId,
|
||||||
|
ausruestungId: opts.ausruestungId,
|
||||||
|
});
|
||||||
return r.data.data;
|
return r.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -25,4 +25,13 @@ export const fahrzeugTypenApi = {
|
|||||||
delete: async (id: number): Promise<void> => {
|
delete: async (id: number): Promise<void> => {
|
||||||
await api.delete(`/api/fahrzeug-typen/${id}`);
|
await api.delete(`/api/fahrzeug-typen/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getTypesForVehicle: async (fahrzeugId: string): Promise<FahrzeugTyp[]> => {
|
||||||
|
const r = await api.get(`/api/fahrzeug-typen/vehicle/${fahrzeugId}`);
|
||||||
|
return r.data.data ?? r.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
setTypesForVehicle: async (fahrzeugId: string, typIds: number[]): Promise<void> => {
|
||||||
|
await api.put(`/api/fahrzeug-typen/vehicle/${fahrzeugId}`, { typIds });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,11 +15,24 @@ export interface ChecklistVorlageItem {
|
|||||||
sort_order: number;
|
sort_order: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AusruestungTyp {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
beschreibung?: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChecklistVorlage {
|
export interface ChecklistVorlage {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
fahrzeug_typ_id?: number;
|
fahrzeug_typ_id?: number;
|
||||||
fahrzeug_typ?: FahrzeugTyp;
|
fahrzeug_typ?: FahrzeugTyp;
|
||||||
|
fahrzeug_id?: string;
|
||||||
|
fahrzeug_name?: string;
|
||||||
|
ausruestung_id?: string;
|
||||||
|
ausruestung_name?: string;
|
||||||
|
ausruestung_typ_id?: number;
|
||||||
|
ausruestung_typ?: string;
|
||||||
intervall?: 'weekly' | 'monthly' | 'yearly' | 'custom';
|
intervall?: 'weekly' | 'monthly' | 'yearly' | 'custom';
|
||||||
intervall_tage?: number;
|
intervall_tage?: number;
|
||||||
beschreibung?: string;
|
beschreibung?: string;
|
||||||
@@ -52,8 +65,10 @@ export interface ChecklistAusfuehrungItem {
|
|||||||
|
|
||||||
export interface ChecklistAusfuehrung {
|
export interface ChecklistAusfuehrung {
|
||||||
id: string;
|
id: string;
|
||||||
fahrzeug_id: string;
|
fahrzeug_id?: string;
|
||||||
fahrzeug_name?: string;
|
fahrzeug_name?: string;
|
||||||
|
ausruestung_id?: string;
|
||||||
|
ausruestung_name?: string;
|
||||||
vorlage_id?: number;
|
vorlage_id?: number;
|
||||||
vorlage_name?: string;
|
vorlage_name?: string;
|
||||||
status: 'offen' | 'abgeschlossen' | 'unvollstaendig' | 'freigegeben';
|
status: 'offen' | 'abgeschlossen' | 'unvollstaendig' | 'freigegeben';
|
||||||
@@ -69,8 +84,10 @@ export interface ChecklistAusfuehrung {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChecklistFaelligkeit {
|
export interface ChecklistFaelligkeit {
|
||||||
fahrzeug_id: string;
|
fahrzeug_id?: string;
|
||||||
fahrzeug_name: string;
|
fahrzeug_name?: string;
|
||||||
|
ausruestung_id?: string;
|
||||||
|
ausruestung_name?: string;
|
||||||
vorlage_id: number;
|
vorlage_id: number;
|
||||||
vorlage_name: string;
|
vorlage_name: string;
|
||||||
naechste_faellig_am: string;
|
naechste_faellig_am: string;
|
||||||
@@ -107,6 +124,9 @@ export interface ChecklistAusfuehrungFilter {
|
|||||||
export interface CreateVorlagePayload {
|
export interface CreateVorlagePayload {
|
||||||
name: string;
|
name: string;
|
||||||
fahrzeug_typ_id?: number;
|
fahrzeug_typ_id?: number;
|
||||||
|
fahrzeug_id?: string;
|
||||||
|
ausruestung_typ_id?: number;
|
||||||
|
ausruestung_id?: string;
|
||||||
intervall?: 'weekly' | 'monthly' | 'yearly' | 'custom';
|
intervall?: 'weekly' | 'monthly' | 'yearly' | 'custom';
|
||||||
intervall_tage?: number;
|
intervall_tage?: number;
|
||||||
beschreibung?: string;
|
beschreibung?: string;
|
||||||
@@ -116,6 +136,9 @@ export interface CreateVorlagePayload {
|
|||||||
export interface UpdateVorlagePayload {
|
export interface UpdateVorlagePayload {
|
||||||
name?: string;
|
name?: string;
|
||||||
fahrzeug_typ_id?: number | null;
|
fahrzeug_typ_id?: number | null;
|
||||||
|
fahrzeug_id?: string | null;
|
||||||
|
ausruestung_typ_id?: number | null;
|
||||||
|
ausruestung_id?: string | null;
|
||||||
intervall?: 'weekly' | 'monthly' | 'yearly' | 'custom' | null;
|
intervall?: 'weekly' | 'monthly' | 'yearly' | 'custom' | null;
|
||||||
intervall_tage?: number | null;
|
intervall_tage?: number | null;
|
||||||
beschreibung?: string | null;
|
beschreibung?: string | null;
|
||||||
@@ -160,3 +183,21 @@ export interface ChecklistWidgetSummary {
|
|||||||
overdue: ChecklistFaelligkeit[];
|
overdue: ChecklistFaelligkeit[];
|
||||||
dueSoon: ChecklistFaelligkeit[];
|
dueSoon: ChecklistFaelligkeit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChecklistOverviewChecklist {
|
||||||
|
vorlage_id: number;
|
||||||
|
vorlage_name: string;
|
||||||
|
next_due?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChecklistOverviewItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kurzname?: string;
|
||||||
|
checklists: ChecklistOverviewChecklist[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChecklistOverviewResponse {
|
||||||
|
vehicles: ChecklistOverviewItem[];
|
||||||
|
equipment: ChecklistOverviewItem[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ export interface AusruestungKategorie {
|
|||||||
motorisiert: boolean;
|
motorisiert: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Equipment Type (many-to-many) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AusruestungTyp {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
beschreibung?: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ── API Response Shapes ──────────────────────────────────────────────────────
|
// ── API Response Shapes ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface AusruestungListItem {
|
export interface AusruestungListItem {
|
||||||
@@ -52,6 +61,7 @@ export interface AusruestungListItem {
|
|||||||
pruefung_tage_bis_faelligkeit: number | null;
|
pruefung_tage_bis_faelligkeit: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
typen?: AusruestungTyp[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AusruestungDetail extends AusruestungListItem {
|
export interface AusruestungDetail extends AusruestungListItem {
|
||||||
|
|||||||
Reference in New Issue
Block a user