feat: vehicle/equipment type system, equipment checklist support, and checklist overview redesign

This commit is contained in:
Matthias Hochmeister
2026-03-28 17:27:01 +01:00
parent 692093cc85
commit 6b46e97eb6
25 changed files with 2230 additions and 494 deletions

View File

@@ -106,6 +106,7 @@ import issueRoutes from './routes/issue.routes';
import buchungskategorieRoutes from './routes/buchungskategorie.routes';
import checklistRoutes from './routes/checklist.routes';
import fahrzeugTypRoutes from './routes/fahrzeugTyp.routes';
import ausruestungTypRoutes from './routes/ausruestungTyp.routes';
app.use('/api/auth', authRoutes);
app.use('/api/user', userRoutes);
@@ -134,6 +135,7 @@ app.use('/api/issues', issueRoutes);
app.use('/api/buchungskategorien', buchungskategorieRoutes);
app.use('/api/checklisten', checklistRoutes);
app.use('/api/fahrzeug-typen', fahrzeugTypRoutes);
app.use('/api/ausruestung-typen', ausruestungTypRoutes);
// Static file serving for uploads (authenticated)
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');

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

View File

@@ -5,14 +5,29 @@ import logger from '../utils/logger';
const param = (req: Request, key: string): string => req.params[key] as string;
class ChecklistController {
// --- Overview ---
async getOverviewItems(_req: Request, res: Response): Promise<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) ---
async getVorlagen(req: Request, res: Response): Promise<void> {
try {
const filter: { fahrzeug_typ_id?: number; aktiv?: boolean } = {};
const filter: { fahrzeug_typ_id?: number; ausruestung_typ_id?: number; aktiv?: boolean } = {};
if (req.query.fahrzeug_typ_id) {
filter.fahrzeug_typ_id = parseInt(req.query.fahrzeug_typ_id as string, 10);
}
if (req.query.ausruestung_typ_id) {
filter.ausruestung_typ_id = parseInt(req.query.ausruestung_typ_id as string, 10);
}
if (req.query.aktiv !== undefined) {
filter.aktiv = req.query.aktiv === 'true';
}
@@ -263,16 +278,108 @@ class ChecklistController {
}
}
// --- Ausführungen (Executions) ---
// --- Templates for equipment ---
async startExecution(req: Request, res: Response): Promise<void> {
const { fahrzeugId, vorlageId } = req.body;
if (!fahrzeugId || !vorlageId) {
res.status(400).json({ success: false, message: 'fahrzeugId und vorlageId sind erforderlich' });
async getTemplatesForEquipment(req: Request, res: Response): Promise<void> {
const ausruestungId = param(req, 'ausruestungId');
if (!ausruestungId) {
res.status(400).json({ success: false, message: 'Ausrüstungs-ID erforderlich' });
return;
}
try {
const execution = await checklistService.startExecution(fahrzeugId, vorlageId, req.user!.id);
const templates = await checklistService.getTemplatesForEquipment(ausruestungId);
res.status(200).json({ success: true, data: templates });
} catch (error) {
logger.error('ChecklistController.getTemplatesForEquipment error', { error });
res.status(500).json({ success: false, message: 'Checklisten konnten nicht geladen werden' });
}
}
// --- Equipment-specific items ---
async getEquipmentItems(req: Request, res: Response): Promise<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 });
} catch (error) {
logger.error('ChecklistController.startExecution error', { error });
@@ -321,8 +428,9 @@ class ChecklistController {
async getExecutions(req: Request, res: Response): Promise<void> {
try {
const filter: { fahrzeugId?: string; vorlageId?: number; status?: string } = {};
const filter: { fahrzeugId?: string; ausruestungId?: string; vorlageId?: number; status?: string } = {};
if (req.query.fahrzeugId) filter.fahrzeugId = req.query.fahrzeugId as string;
if (req.query.ausruestungId) filter.ausruestungId = req.query.ausruestungId as string;
if (req.query.vorlageId) filter.vorlageId = parseInt(req.query.vorlageId as string, 10);
if (req.query.status) filter.status = req.query.status as string;
const executions = await checklistService.getExecutions(filter);

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

View File

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

View File

@@ -13,15 +13,19 @@ async function runChecklistReminderCheck(): Promise<void> {
}
isRunning = true;
try {
// Find overdue checklists
// Find overdue checklists (vehicles + equipment)
const result = await pool.query(`
SELECT cf.fahrzeug_id, cf.vorlage_id, cf.naechste_faellig_am,
SELECT cf.fahrzeug_id, cf.ausruestung_id, cf.vorlage_id, cf.naechste_faellig_am,
f.bezeichnung AS fahrzeug_name,
ar.name AS ausruestung_name,
v.name AS vorlage_name
FROM checklist_faelligkeit cf
JOIN fahrzeuge f ON f.id = cf.fahrzeug_id AND f.deleted_at IS NULL
LEFT JOIN fahrzeuge f ON f.id = cf.fahrzeug_id AND f.deleted_at IS NULL
LEFT JOIN ausruestung ar ON ar.id = cf.ausruestung_id
JOIN checklist_vorlagen v ON v.id = cf.vorlage_id AND v.aktiv = true
WHERE cf.naechste_faellig_am <= CURRENT_DATE
AND (cf.fahrzeug_id IS NOT NULL OR cf.ausruestung_id IS NOT NULL)
AND (cf.fahrzeug_id IS NULL OR f.id IS NOT NULL)
`);
if (result.rows.length === 0) return;
@@ -40,16 +44,21 @@ async function runChecklistReminderCheck(): Promise<void> {
day: '2-digit', month: '2-digit', year: 'numeric',
});
// Notify first responsible user (avoid spam by using quell_id dedup)
const targetName = row.fahrzeug_name || row.ausruestung_name || 'Unbekannt';
const quellId = row.fahrzeug_id
? `${row.fahrzeug_id}_${row.vorlage_id}`
: `eq_${row.ausruestung_id}_${row.vorlage_id}`;
// Notify responsible users (dedup handled by quell_id)
for (const userId of targetUserIds) {
await notificationService.createNotification({
user_id: userId,
typ: 'checklist_faellig',
titel: `Checkliste überfällig: ${row.vorlage_name}`,
nachricht: `Die Checkliste "${row.vorlage_name}" für ${row.fahrzeug_name} war fällig am ${faelligDatum}`,
nachricht: `Die Checkliste "${row.vorlage_name}" für ${targetName} war fällig am ${faelligDatum}`,
schwere: 'warnung',
link: `/checklisten`,
quell_id: `${row.fahrzeug_id}_${row.vorlage_id}`,
quell_id: quellId,
quell_typ: 'checklist_faellig',
});
}

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

View File

@@ -13,6 +13,14 @@ router.get(
checklistController.getOverdueChecklists.bind(checklistController)
);
// --- Overview ---
router.get(
'/overview',
authenticate,
requirePermission('checklisten:view'),
checklistController.getOverviewItems.bind(checklistController)
);
// --- Vorlagen (Templates) ---
router.get(
'/vorlagen',
@@ -124,6 +132,42 @@ router.get(
checklistController.getDueChecklists.bind(checklistController)
);
// --- Equipment-specific items ---
router.get(
'/equipment/:ausruestungId/vorlagen',
authenticate,
requirePermission('checklisten:view'),
checklistController.getTemplatesForEquipment.bind(checklistController)
);
router.get(
'/equipment/:ausruestungId/items',
authenticate,
requirePermission('checklisten:view'),
checklistController.getEquipmentItems.bind(checklistController)
);
router.post(
'/equipment/:ausruestungId/items',
authenticate,
requirePermission('checklisten:manage_templates'),
checklistController.addEquipmentItem.bind(checklistController)
);
router.patch(
'/equipment/:ausruestungId/items/:itemId',
authenticate,
requirePermission('checklisten:manage_templates'),
checklistController.updateEquipmentItem.bind(checklistController)
);
router.delete(
'/equipment/:ausruestungId/items/:itemId',
authenticate,
requirePermission('checklisten:manage_templates'),
checklistController.deleteEquipmentItem.bind(checklistController)
);
// --- Ausführungen (Executions) ---
router.get(
'/ausfuehrungen',

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

View File

@@ -30,7 +30,7 @@ function calculateNextDueDate(intervall: string | null, intervall_tage: number |
// Vorlagen (Templates)
// ---------------------------------------------------------------------------
async function getVorlagen(filter?: { fahrzeug_typ_id?: number; aktiv?: boolean }) {
async function getVorlagen(filter?: { fahrzeug_typ_id?: number; ausruestung_typ_id?: number; aktiv?: boolean }) {
try {
const conditions: string[] = [];
const values: any[] = [];
@@ -41,6 +41,11 @@ async function getVorlagen(filter?: { fahrzeug_typ_id?: number; aktiv?: boolean
values.push(filter.fahrzeug_typ_id);
idx++;
}
if (filter?.ausruestung_typ_id !== undefined) {
conditions.push(`v.ausruestung_typ_id = $${idx}`);
values.push(filter.ausruestung_typ_id);
idx++;
}
if (filter?.aktiv !== undefined) {
conditions.push(`v.aktiv = $${idx}`);
values.push(filter.aktiv);
@@ -49,9 +54,12 @@ async function getVorlagen(filter?: { fahrzeug_typ_id?: number; aktiv?: boolean
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const result = await pool.query(
`SELECT v.*, ft.name AS fahrzeug_typ_name
`SELECT v.*,
ft.name AS fahrzeug_typ_name,
at.name AS ausruestung_typ_name
FROM checklist_vorlagen v
LEFT JOIN fahrzeug_typen ft ON ft.id = v.fahrzeug_typ_id
LEFT JOIN ausruestung_typen at ON at.id = v.ausruestung_typ_id
${where}
ORDER BY v.name ASC`,
values
@@ -66,9 +74,12 @@ async function getVorlagen(filter?: { fahrzeug_typ_id?: number; aktiv?: boolean
async function getVorlageById(id: number) {
try {
const vorlageResult = await pool.query(
`SELECT v.*, ft.name AS fahrzeug_typ_name
`SELECT v.*,
ft.name AS fahrzeug_typ_name,
at.name AS ausruestung_typ_name
FROM checklist_vorlagen v
LEFT JOIN fahrzeug_typen ft ON ft.id = v.fahrzeug_typ_id
LEFT JOIN ausruestung_typen at ON at.id = v.ausruestung_typ_id
WHERE v.id = $1`,
[id]
);
@@ -90,18 +101,24 @@ async function getVorlageById(id: number) {
async function createVorlage(data: {
name: string;
fahrzeug_typ_id?: number | null;
fahrzeug_id?: string | null;
ausruestung_typ_id?: number | null;
ausruestung_id?: string | null;
intervall?: string | null;
intervall_tage?: number | null;
beschreibung?: string | null;
}) {
try {
const result = await pool.query(
`INSERT INTO checklist_vorlagen (name, fahrzeug_typ_id, intervall, intervall_tage, beschreibung)
VALUES ($1, $2, $3, $4, $5)
`INSERT INTO checklist_vorlagen (name, fahrzeug_typ_id, fahrzeug_id, ausruestung_typ_id, ausruestung_id, intervall, intervall_tage, beschreibung)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[
data.name,
data.fahrzeug_typ_id ?? null,
data.fahrzeug_id ?? null,
data.ausruestung_typ_id ?? null,
data.ausruestung_id ?? null,
data.intervall ?? null,
data.intervall_tage ?? null,
data.beschreibung ?? null,
@@ -117,6 +134,9 @@ async function createVorlage(data: {
async function updateVorlage(id: number, data: {
name?: string;
fahrzeug_typ_id?: number | null;
fahrzeug_id?: string | null;
ausruestung_typ_id?: number | null;
ausruestung_id?: string | null;
intervall?: string | null;
intervall_tage?: number | null;
beschreibung?: string | null;
@@ -129,6 +149,9 @@ async function updateVorlage(id: number, data: {
if (data.name !== undefined) { setClauses.push(`name = $${idx}`); values.push(data.name); idx++; }
if ('fahrzeug_typ_id' in data) { setClauses.push(`fahrzeug_typ_id = $${idx}`); values.push(data.fahrzeug_typ_id); idx++; }
if ('fahrzeug_id' in data) { setClauses.push(`fahrzeug_id = $${idx}`); values.push(data.fahrzeug_id); idx++; }
if ('ausruestung_typ_id' in data) { setClauses.push(`ausruestung_typ_id = $${idx}`); values.push(data.ausruestung_typ_id); idx++; }
if ('ausruestung_id' in data) { setClauses.push(`ausruestung_id = $${idx}`); values.push(data.ausruestung_id); idx++; }
if ('intervall' in data) { setClauses.push(`intervall = $${idx}`); values.push(data.intervall); idx++; }
if ('intervall_tage' in data) { setClauses.push(`intervall_tage = $${idx}`); values.push(data.intervall_tage); idx++; }
if ('beschreibung' in data) { setClauses.push(`beschreibung = $${idx}`); values.push(data.beschreibung); idx++; }
@@ -329,23 +352,111 @@ async function deleteVehicleItem(id: number) {
}
}
// ---------------------------------------------------------------------------
// Ausrüstung-spezifische Items
// ---------------------------------------------------------------------------
async function getEquipmentItems(ausruestungId: string) {
try {
const result = await pool.query(
`SELECT * FROM ausruestung_checklist_items WHERE ausruestung_id = $1 AND aktiv = true ORDER BY sort_order ASC, id ASC`,
[ausruestungId]
);
return result.rows;
} catch (error) {
logger.error('ChecklistService.getEquipmentItems failed', { error, ausruestungId });
throw new Error('Ausrüstungs-Items konnten nicht geladen werden');
}
}
async function addEquipmentItem(ausruestungId: string, data: {
bezeichnung: string;
beschreibung?: string | null;
pflicht?: boolean;
sort_order?: number;
}) {
try {
const result = await pool.query(
`INSERT INTO ausruestung_checklist_items (ausruestung_id, bezeichnung, beschreibung, pflicht, sort_order)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[ausruestungId, data.bezeichnung, data.beschreibung ?? null, data.pflicht ?? true, data.sort_order ?? 0]
);
return result.rows[0];
} catch (error) {
logger.error('ChecklistService.addEquipmentItem failed', { error, ausruestungId });
throw new Error('Ausrüstungs-Item konnte nicht erstellt werden');
}
}
async function updateEquipmentItem(id: number, data: {
bezeichnung?: string;
beschreibung?: string | null;
pflicht?: boolean;
sort_order?: number;
aktiv?: boolean;
}) {
try {
const setClauses: string[] = [];
const values: any[] = [];
let idx = 1;
if (data.bezeichnung !== undefined) { setClauses.push(`bezeichnung = $${idx}`); values.push(data.bezeichnung); idx++; }
if ('beschreibung' in data) { setClauses.push(`beschreibung = $${idx}`); values.push(data.beschreibung); idx++; }
if (data.pflicht !== undefined) { setClauses.push(`pflicht = $${idx}`); values.push(data.pflicht); idx++; }
if (data.sort_order !== undefined) { setClauses.push(`sort_order = $${idx}`); values.push(data.sort_order); idx++; }
if (data.aktiv !== undefined) { setClauses.push(`aktiv = $${idx}`); values.push(data.aktiv); idx++; }
if (setClauses.length === 0) {
const r = await pool.query(`SELECT * FROM ausruestung_checklist_items WHERE id = $1`, [id]);
return r.rows[0] || null;
}
values.push(id);
const result = await pool.query(
`UPDATE ausruestung_checklist_items SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`,
values
);
return result.rows[0] || null;
} catch (error) {
logger.error('ChecklistService.updateEquipmentItem failed', { error, id });
throw new Error('Ausrüstungs-Item konnte nicht aktualisiert werden');
}
}
async function deleteEquipmentItem(id: number) {
try {
const result = await pool.query(
`UPDATE ausruestung_checklist_items SET aktiv = false WHERE id = $1 RETURNING *`,
[id]
);
return result.rows[0] || null;
} catch (error) {
logger.error('ChecklistService.deleteEquipmentItem failed', { error, id });
throw new Error('Ausrüstungs-Item konnte nicht deaktiviert werden');
}
}
// ---------------------------------------------------------------------------
// Templates for a specific vehicle (via type junction)
// ---------------------------------------------------------------------------
async function getTemplatesForVehicle(fahrzeugId: string) {
try {
// Templates that match the vehicle's types, or global templates (no type)
// Templates that match the vehicle directly, by type, or global (no assignment)
const result = await pool.query(
`SELECT DISTINCT v.*, ft.name AS fahrzeug_typ_name
FROM checklist_vorlagen v
LEFT JOIN fahrzeug_typen ft ON ft.id = v.fahrzeug_typ_id
WHERE v.aktiv = true
AND v.ausruestung_id IS NULL
AND v.ausruestung_typ_id IS NULL
AND (
v.fahrzeug_typ_id IS NULL
v.fahrzeug_id = $1
OR v.fahrzeug_typ_id IN (
SELECT fahrzeug_typ_id FROM fahrzeug_fahrzeug_typen WHERE fahrzeug_id = $1
)
OR (v.fahrzeug_typ_id IS NULL AND v.fahrzeug_id IS NULL)
)
ORDER BY v.name ASC`,
[fahrzeugId]
@@ -367,21 +478,109 @@ async function getTemplatesForVehicle(fahrzeugId: string) {
}
}
// ---------------------------------------------------------------------------
// Templates for a specific equipment item (via type junction)
// ---------------------------------------------------------------------------
async function getTemplatesForEquipment(ausruestungId: string) {
try {
const result = await pool.query(
`SELECT DISTINCT v.*, at.name AS ausruestung_typ_name
FROM checklist_vorlagen v
LEFT JOIN ausruestung_typen at ON at.id = v.ausruestung_typ_id
WHERE v.aktiv = true
AND (
v.ausruestung_id = $1
OR v.ausruestung_typ_id IN (
SELECT ausruestung_typ_id FROM ausruestung_ausruestung_typen WHERE ausruestung_id = $1
)
OR (v.fahrzeug_typ_id IS NULL AND v.fahrzeug_id IS NULL AND v.ausruestung_typ_id IS NULL AND v.ausruestung_id IS NULL)
)
ORDER BY v.name ASC`,
[ausruestungId]
);
// Attach items to each template
for (const vorlage of result.rows) {
const items = await pool.query(
`SELECT * FROM checklist_vorlage_items WHERE vorlage_id = $1 ORDER BY sort_order ASC, id ASC`,
[vorlage.id]
);
vorlage.items = items.rows;
}
return result.rows;
} catch (error) {
logger.error('ChecklistService.getTemplatesForEquipment failed', { error, ausruestungId });
throw new Error('Checklisten für Ausrüstung konnten nicht geladen werden');
}
}
// ---------------------------------------------------------------------------
// Overview Items (vehicles + equipment with open checklists)
// ---------------------------------------------------------------------------
async function getOverviewItems() {
try {
// Vehicles with overdue or upcoming checklists (within 7 days)
const vehiclesResult = await pool.query(`
SELECT f.id, f.bezeichnung AS name, f.kurzname,
json_agg(json_build_object(
'vorlage_id', cf.vorlage_id,
'vorlage_name', v.name,
'next_due', cf.naechste_faellig_am
) ORDER BY cf.naechste_faellig_am ASC) AS checklists
FROM checklist_faelligkeit cf
JOIN fahrzeuge f ON f.id = cf.fahrzeug_id AND f.deleted_at IS NULL
JOIN checklist_vorlagen v ON v.id = cf.vorlage_id AND v.aktiv = true
WHERE cf.fahrzeug_id IS NOT NULL
AND cf.naechste_faellig_am <= CURRENT_DATE + INTERVAL '7 days'
GROUP BY f.id, f.bezeichnung, f.kurzname
ORDER BY MIN(cf.naechste_faellig_am) ASC
`);
// Equipment with overdue or upcoming checklists (within 7 days)
const equipmentResult = await pool.query(`
SELECT a.id, a.name,
json_agg(json_build_object(
'vorlage_id', cf.vorlage_id,
'vorlage_name', v.name,
'next_due', cf.naechste_faellig_am
) ORDER BY cf.naechste_faellig_am ASC) AS checklists
FROM checklist_faelligkeit cf
JOIN ausruestung a ON a.id = cf.ausruestung_id
JOIN checklist_vorlagen v ON v.id = cf.vorlage_id AND v.aktiv = true
WHERE cf.ausruestung_id IS NOT NULL
AND cf.naechste_faellig_am <= CURRENT_DATE + INTERVAL '7 days'
GROUP BY a.id, a.name
ORDER BY MIN(cf.naechste_faellig_am) ASC
`);
return {
vehicles: vehiclesResult.rows,
equipment: equipmentResult.rows,
};
} catch (error) {
logger.error('ChecklistService.getOverviewItems failed', { error });
throw new Error('Übersichtsdaten konnten nicht geladen werden');
}
}
// ---------------------------------------------------------------------------
// Ausführungen (Executions)
// ---------------------------------------------------------------------------
async function startExecution(fahrzeugId: string, vorlageId: number, userId: string) {
async function startExecution(fahrzeugId: string | null, vorlageId: number, userId: string, ausruestungId?: string | null) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Create the execution record
const execResult = await client.query(
`INSERT INTO checklist_ausfuehrungen (fahrzeug_id, vorlage_id, ausgefuehrt_von)
VALUES ($1, $2, $3)
`INSERT INTO checklist_ausfuehrungen (fahrzeug_id, ausruestung_id, vorlage_id, ausgefuehrt_von)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[fahrzeugId, vorlageId, userId]
[fahrzeugId || null, ausruestungId || null, vorlageId, userId]
);
const execution = execResult.rows[0];
@@ -398,17 +597,31 @@ async function startExecution(fahrzeugId: string, vorlageId: number, userId: str
);
}
// Copy vehicle-specific items
const vehicleItems = await client.query(
`SELECT * FROM fahrzeug_checklist_items WHERE fahrzeug_id = $1 AND aktiv = true ORDER BY sort_order ASC, id ASC`,
[fahrzeugId]
);
for (const item of vehicleItems.rows) {
await client.query(
`INSERT INTO checklist_ausfuehrung_items (ausfuehrung_id, fahrzeug_item_id, bezeichnung)
VALUES ($1, $2, $3)`,
[execution.id, item.id, item.bezeichnung]
// Copy entity-specific items (vehicle or equipment)
if (ausruestungId) {
const equipmentItems = await client.query(
`SELECT * FROM ausruestung_checklist_items WHERE ausruestung_id = $1 AND aktiv = true ORDER BY sort_order ASC, id ASC`,
[ausruestungId]
);
for (const item of equipmentItems.rows) {
await client.query(
`INSERT INTO checklist_ausfuehrung_items (ausfuehrung_id, ausruestung_item_id, bezeichnung)
VALUES ($1, $2, $3)`,
[execution.id, item.id, item.bezeichnung]
);
}
} else if (fahrzeugId) {
const vehicleItems = await client.query(
`SELECT * FROM fahrzeug_checklist_items WHERE fahrzeug_id = $1 AND aktiv = true ORDER BY sort_order ASC, id ASC`,
[fahrzeugId]
);
for (const item of vehicleItems.rows) {
await client.query(
`INSERT INTO checklist_ausfuehrung_items (ausfuehrung_id, fahrzeug_item_id, bezeichnung)
VALUES ($1, $2, $3)`,
[execution.id, item.id, item.bezeichnung]
);
}
}
await client.query('COMMIT');
@@ -429,11 +642,13 @@ async function getExecutionById(id: string) {
const execResult = await pool.query(
`SELECT a.*,
f.bezeichnung AS fahrzeug_name, f.kurzname AS fahrzeug_kurzname,
ar.name AS ausruestung_name,
v.name AS vorlage_name,
u1.name AS ausgefuehrt_von_name,
u2.name AS freigegeben_von_name
FROM checklist_ausfuehrungen a
LEFT JOIN fahrzeuge f ON f.id = a.fahrzeug_id
LEFT JOIN ausruestung ar ON ar.id = a.ausruestung_id
LEFT JOIN checklist_vorlagen v ON v.id = a.vorlage_id
LEFT JOIN users u1 ON u1.id = a.ausgefuehrt_von
LEFT JOIN users u2 ON u2.id = a.freigegeben_von
@@ -475,12 +690,13 @@ async function submitExecution(
// Check if all pflicht items have ergebnis = 'ok'
const pflichtCheck = await client.query(
`SELECT ai.id, ai.ergebnis, ai.vorlage_item_id, ai.fahrzeug_item_id
`SELECT ai.id, ai.ergebnis, ai.vorlage_item_id, ai.fahrzeug_item_id, ai.ausruestung_item_id
FROM checklist_ausfuehrung_items ai
LEFT JOIN checklist_vorlage_items vi ON vi.id = ai.vorlage_item_id
LEFT JOIN fahrzeug_checklist_items fi ON fi.id = ai.fahrzeug_item_id
LEFT JOIN ausruestung_checklist_items aci ON aci.id = ai.ausruestung_item_id
WHERE ai.ausfuehrung_id = $1
AND (COALESCE(vi.pflicht, fi.pflicht, true) = true)`,
AND (COALESCE(vi.pflicht, fi.pflicht, aci.pflicht, true) = true)`,
[id]
);
@@ -494,20 +710,30 @@ async function submitExecution(
// Update checklist_faelligkeit if completed
if (allPflichtOk) {
const exec = await client.query(`SELECT vorlage_id, fahrzeug_id FROM checklist_ausfuehrungen WHERE id = $1`, [id]);
const exec = await client.query(`SELECT vorlage_id, fahrzeug_id, ausruestung_id FROM checklist_ausfuehrungen WHERE id = $1`, [id]);
if (exec.rows.length > 0) {
const { vorlage_id, fahrzeug_id } = exec.rows[0];
const { vorlage_id, fahrzeug_id, ausruestung_id } = exec.rows[0];
const vorlage = await client.query(`SELECT intervall, intervall_tage FROM checklist_vorlagen WHERE id = $1`, [vorlage_id]);
if (vorlage.rows.length > 0) {
const nextDue = calculateNextDueDate(vorlage.rows[0].intervall, vorlage.rows[0].intervall_tage);
if (nextDue) {
await client.query(
`INSERT INTO checklist_faelligkeit (fahrzeug_id, vorlage_id, naechste_faellig_am, letzte_ausfuehrung_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (fahrzeug_id, vorlage_id) DO UPDATE
SET naechste_faellig_am = $3, letzte_ausfuehrung_id = $4`,
[fahrzeug_id, vorlage_id, nextDue, id]
);
if (ausruestung_id) {
await client.query(
`INSERT INTO checklist_faelligkeit (ausruestung_id, vorlage_id, naechste_faellig_am, letzte_ausfuehrung_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (vorlage_id, ausruestung_id) WHERE ausruestung_id IS NOT NULL AND fahrzeug_id IS NULL
DO UPDATE SET naechste_faellig_am = $3, letzte_ausfuehrung_id = $4`,
[ausruestung_id, vorlage_id, nextDue, id]
);
} else if (fahrzeug_id) {
await client.query(
`INSERT INTO checklist_faelligkeit (fahrzeug_id, vorlage_id, naechste_faellig_am, letzte_ausfuehrung_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (vorlage_id, fahrzeug_id) WHERE fahrzeug_id IS NOT NULL AND ausruestung_id IS NULL
DO UPDATE SET naechste_faellig_am = $3, letzte_ausfuehrung_id = $4`,
[fahrzeug_id, vorlage_id, nextDue, id]
);
}
}
}
}
@@ -541,7 +767,7 @@ async function approveExecution(id: string, userId: string) {
}
}
async function getExecutions(filter?: { fahrzeugId?: string; vorlageId?: number; status?: string }) {
async function getExecutions(filter?: { fahrzeugId?: string; ausruestungId?: string; vorlageId?: number; status?: string }) {
try {
const conditions: string[] = [];
const values: any[] = [];
@@ -552,6 +778,11 @@ async function getExecutions(filter?: { fahrzeugId?: string; vorlageId?: number;
values.push(filter.fahrzeugId);
idx++;
}
if (filter?.ausruestungId) {
conditions.push(`a.ausruestung_id = $${idx}`);
values.push(filter.ausruestungId);
idx++;
}
if (filter?.vorlageId) {
conditions.push(`a.vorlage_id = $${idx}`);
values.push(filter.vorlageId);
@@ -567,11 +798,13 @@ async function getExecutions(filter?: { fahrzeugId?: string; vorlageId?: number;
const result = await pool.query(
`SELECT a.*,
f.bezeichnung AS fahrzeug_name, f.kurzname AS fahrzeug_kurzname,
ar.name AS ausruestung_name,
v.name AS vorlage_name,
u1.name AS ausgefuehrt_von_name,
u2.name AS freigegeben_von_name
FROM checklist_ausfuehrungen a
LEFT JOIN fahrzeuge f ON f.id = a.fahrzeug_id
LEFT JOIN ausruestung ar ON ar.id = a.ausruestung_id
LEFT JOIN checklist_vorlagen v ON v.id = a.vorlage_id
LEFT JOIN users u1 ON u1.id = a.ausgefuehrt_von
LEFT JOIN users u2 ON u2.id = a.freigegeben_von
@@ -593,12 +826,17 @@ async function getExecutions(filter?: { fahrzeugId?: string; vorlageId?: number;
async function getOverdueChecklists() {
try {
const result = await pool.query(`
SELECT cf.*, f.bezeichnung AS fahrzeug_name, f.kurzname AS fahrzeug_kurzname,
SELECT cf.*,
f.bezeichnung AS fahrzeug_name, f.kurzname AS fahrzeug_kurzname,
ar.name AS ausruestung_name,
v.name AS vorlage_name
FROM checklist_faelligkeit cf
JOIN fahrzeuge f ON f.id = cf.fahrzeug_id AND f.deleted_at IS NULL
LEFT JOIN fahrzeuge f ON f.id = cf.fahrzeug_id AND f.deleted_at IS NULL
LEFT JOIN ausruestung ar ON ar.id = cf.ausruestung_id
JOIN checklist_vorlagen v ON v.id = cf.vorlage_id AND v.aktiv = true
WHERE cf.naechste_faellig_am <= CURRENT_DATE
AND (cf.fahrzeug_id IS NOT NULL OR cf.ausruestung_id IS NOT NULL)
AND (cf.fahrzeug_id IS NULL OR f.id IS NOT NULL)
ORDER BY cf.naechste_faellig_am ASC
`);
return result.rows;
@@ -639,7 +877,13 @@ export default {
addVehicleItem,
updateVehicleItem,
deleteVehicleItem,
getEquipmentItems,
addEquipmentItem,
updateEquipmentItem,
deleteEquipmentItem,
getTemplatesForVehicle,
getTemplatesForEquipment,
getOverviewItems,
startExecution,
getExecutionById,
submitExecution,

View File

@@ -19,6 +19,7 @@ import BookingFormPage from './pages/BookingFormPage';
import Ausruestung from './pages/Ausruestung';
import AusruestungForm from './pages/AusruestungForm';
import AusruestungDetail from './pages/AusruestungDetail';
import AusruestungEinstellungen from './pages/AusruestungEinstellungen';
import Atemschutz from './pages/Atemschutz';
import Mitglieder from './pages/Mitglieder';
import MitgliedDetail from './pages/MitgliedDetail';
@@ -37,6 +38,7 @@ import AusruestungsanfrageZuBestellung from './pages/AusruestungsanfrageZuBestel
import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail';
import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu';
import Checklisten from './pages/Checklisten';
import FahrzeugEinstellungen from './pages/FahrzeugEinstellungen';
import ChecklistAusfuehrung from './pages/ChecklistAusfuehrung';
import Issues from './pages/Issues';
import IssueDetail from './pages/IssueDetail';
@@ -120,6 +122,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/fahrzeuge/einstellungen"
element={
<ProtectedRoute>
<FahrzeugEinstellungen />
</ProtectedRoute>
}
/>
<Route
path="/fahrzeuge/:id/bearbeiten"
element={
@@ -152,6 +162,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/ausruestung/einstellungen"
element={
<ProtectedRoute>
<AusruestungEinstellungen />
</ProtectedRoute>
}
/>
<Route
path="/ausruestung/:id/bearbeiten"
element={

View File

@@ -63,10 +63,11 @@ function ChecklistWidget() {
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{overdueItems.slice(0, 5).map((item) => {
const days = Math.ceil((Date.now() - new Date(item.naechste_faellig_am).getTime()) / 86400000);
const targetName = item.fahrzeug_name || item.ausruestung_name || '';
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%' }}>
{item.fahrzeug_name}
{targetName}
</Typography>
<Chip
label={`${item.vorlage_name} \u2013 ${days > 0 ? `${days}d` : 'heute'}`}

View File

@@ -192,12 +192,17 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
const menuOrder: string[] = (preferences?.menuOrder as string[] | undefined) ?? [];
const vehicleSubItems: SubItem[] = useMemo(
() =>
(vehicleList ?? []).map((v) => ({
() => {
const items: SubItem[] = (vehicleList ?? []).map((v) => ({
text: v.bezeichnung ?? v.kurzname,
path: `/fahrzeuge/${v.id}`,
})),
[vehicleList],
}));
if (hasPermission('fahrzeuge:edit')) {
items.push({ text: 'Einstellungen', path: '/fahrzeuge/einstellungen' });
}
return items;
},
[vehicleList, hasPermission],
);
const navigationItems = useMemo((): NavigationItem[] => {
@@ -234,13 +239,19 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
];
if (hasPermission('checklisten:manage_templates')) {
checklistenSubItems.push({ text: 'Vorlagen', path: '/checklisten?tab=1' });
checklistenSubItems.push({ text: 'Fahrzeugtypen', path: '/checklisten?tab=2' });
}
checklistenSubItems.push({ text: 'Historie', path: `/checklisten?tab=${checklistenSubItems.length}` });
const items = baseNavigationItems
.map((item) => {
if (item.path === '/fahrzeuge') return fahrzeugeItem;
if (item.path === '/ausruestung') {
const ausruestungSubs: SubItem[] = [];
if (hasPermission('ausruestung:manage_types')) {
ausruestungSubs.push({ text: 'Einstellungen', path: '/ausruestung/einstellungen' });
}
return ausruestungSubs.length > 0 ? { ...item, subItems: ausruestungSubs } : item;
}
if (item.path === '/ausruestungsanfrage') {
const canSeeAusruestung =
hasPermission('ausruestungsanfrage:view') ||

View File

@@ -9,10 +9,6 @@ import {
Chip,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
FormControlLabel,
Grid,
@@ -22,12 +18,6 @@ import {
MenuItem,
Select,
Switch,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Tooltip,
Typography,
@@ -36,22 +26,20 @@ import {
Add,
Build,
CheckCircle,
Close,
Delete,
Edit,
Error as ErrorIcon,
LinkRounded,
PauseCircle,
RemoveCircle,
Save,
Search,
Settings,
Star,
Warning,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { equipmentApi } from '../services/equipment';
import { ausruestungTypenApi } from '../services/ausruestungTypen';
import {
AusruestungListItem,
AusruestungKategorie,
@@ -60,7 +48,6 @@ import {
EquipmentStats,
} from '../types/equipment.types';
import { usePermissions } from '../hooks/usePermissions';
import { useNotification } from '../contexts/NotificationContext';
import ChatAwareFab from '../components/shared/ChatAwareFab';
// ── Status chip config ────────────────────────────────────────────────────────
@@ -169,13 +156,23 @@ const EquipmentCard: React.FC<EquipmentCardProps> = ({ item, onClick }) => {
<Typography variant="h6" component="div" lineHeight={1.2} noWrap>
{item.bezeichnung}
</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
label={item.kategorie_kurzname}
size="small"
variant="outlined"
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>
@@ -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 ─────────────────────────────────────────────────────────────────
function Ausruestung() {
const navigate = useNavigate();
const { canManageEquipment, hasPermission } = usePermissions();
const canManageCategories = hasPermission('ausruestung:manage_categories');
// Category dialog state
const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
const canManageTypes = hasPermission('ausruestung:manage_types');
// Data state
const [equipment, setEquipment] = useState<AusruestungListItem[]>([]);
@@ -424,9 +247,16 @@ function Ausruestung() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Equipment types for filter
const { data: typen = [] } = useQuery({
queryKey: ['ausruestungTypen'],
queryFn: ausruestungTypenApi.getAll,
});
// Filter state
const [search, setSearch] = useState('');
const [selectedCategory, setSelectedCategory] = useState('');
const [selectedTyp, setSelectedTyp] = useState('');
const [selectedStatus, setSelectedStatus] = useState('');
const [nurWichtige, setNurWichtige] = useState(false);
const [pruefungFaellig, setPruefungFaellig] = useState(false);
@@ -471,6 +301,14 @@ function Ausruestung() {
return false;
}
// Type filter
if (selectedTyp) {
const typId = parseInt(selectedTyp, 10);
if (!item.typen?.some((t) => t.id === typId)) {
return false;
}
}
// Status filter
if (selectedStatus && item.status !== selectedStatus) {
return false;
@@ -490,7 +328,7 @@ function Ausruestung() {
return true;
});
}, [equipment, search, selectedCategory, selectedStatus, nurWichtige, pruefungFaellig]);
}, [equipment, search, selectedCategory, selectedTyp, selectedStatus, nurWichtige, pruefungFaellig]);
const hasOverdue = equipment.some(
(item) => item.pruefung_tage_bis_faelligkeit !== null && item.pruefung_tage_bis_faelligkeit < 0
@@ -506,9 +344,9 @@ function Ausruestung() {
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
Ausrüstungsverwaltung
</Typography>
{canManageCategories && (
<Tooltip title="Kategorien verwalten">
<IconButton onClick={() => setCategoryDialogOpen(true)} size="small">
{canManageTypes && (
<Tooltip title="Einstellungen">
<IconButton onClick={() => navigate('/ausruestung/einstellungen')} size="small">
<Settings />
</IconButton>
</Tooltip>
@@ -576,6 +414,22 @@ function Ausruestung() {
</Select>
</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 }}>
<InputLabel>Status</InputLabel>
<Select
@@ -673,15 +527,6 @@ function Ausruestung() {
<Add />
</ChatAwareFab>
)}
{/* Category management dialog */}
{canManageCategories && (
<CategoryManagementDialog
open={categoryDialogOpen}
onClose={() => setCategoryDialogOpen(false)}
categories={categories}
onRefresh={fetchData}
/>
)}
</Container>
</DashboardLayout>
);

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Alert,
Autocomplete,
Box,
Button,
Chip,
@@ -44,14 +45,18 @@ import {
MoreHoriz,
PauseCircle,
RemoveCircle,
Save,
Star,
Verified,
Warning,
} from '@mui/icons-material';
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 ChatAwareFab from '../components/shared/ChatAwareFab';
import { equipmentApi } from '../services/equipment';
import { ausruestungTypenApi } from '../services/ausruestungTypen';
import type { AusruestungTyp } from '../services/ausruestungTypen';
import { fromGermanDate } from '../utils/dateInput';
import {
AusruestungDetail,
@@ -197,9 +202,10 @@ interface UebersichtTabProps {
equipment: AusruestungDetail;
onStatusUpdated: () => void;
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 [newStatus, setNewStatus] = useState<AusruestungStatus>(equipment.status);
const [bemerkung, setBemerkung] = useState(equipment.status_bemerkung ?? '');
@@ -406,6 +412,109 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ equipment, onStatusUpdate
</Dialog>
<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>
);
};
@@ -845,6 +954,7 @@ function AusruestungDetailPage() {
equipment={equipment}
onStatusUpdated={fetchEquipment}
canChangeStatus={canWrite}
canWrite={canWrite}
/>
</TabPanel>

View 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 &quot;{deletingTyp?.name}&quot; 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;

View File

@@ -57,14 +57,15 @@ export default function ChecklistAusfuehrung() {
useEffect(() => {
if (!isNew || startingExecution) return;
const fahrzeugId = searchParams.get('fahrzeug');
const ausruestungId = searchParams.get('ausruestung');
const vorlageId = searchParams.get('vorlage');
if (!fahrzeugId || !vorlageId) {
showError('Fahrzeug und Vorlage sind erforderlich');
if (!vorlageId || (!fahrzeugId && !ausruestungId)) {
showError('Vorlage und entweder Fahrzeug oder Ausrüstung sind erforderlich');
navigate('/checklisten');
return;
}
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 }))
.catch(() => { showError('Checkliste konnte nicht gestartet werden'); navigate('/checklisten'); });
}, [isNew, searchParams, navigate, showError, startingExecution]);

View File

@@ -1,9 +1,13 @@
import React, { useState, useEffect } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Alert,
Autocomplete,
Badge,
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
Dialog,
@@ -12,10 +16,16 @@ import {
DialogTitle,
FormControl,
FormControlLabel,
FormLabel,
IconButton,
InputLabel,
List,
ListItem,
ListItemText,
MenuItem,
Paper,
Radio,
RadioGroup,
Select,
Switch,
Tab,
@@ -32,12 +42,12 @@ import {
} from '@mui/material';
import {
Add as AddIcon,
CheckCircle,
BuildCircle,
Delete as DeleteIcon,
DirectionsCar,
Edit as EditIcon,
ExpandMore,
PlayArrow,
Schedule,
Warning,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useSearchParams } from 'react-router-dom';
@@ -46,7 +56,9 @@ import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext';
import { checklistenApi } from '../services/checklisten';
import { fahrzeugTypenApi } from '../services/fahrzeugTypen';
import { ausruestungTypenApi } from '../services/ausruestungTypen';
import { vehiclesApi } from '../services/vehicles';
import { equipmentApi } from '../services/equipment';
import {
CHECKLIST_STATUS_LABELS,
CHECKLIST_STATUS_COLORS,
@@ -54,12 +66,14 @@ import {
import type {
ChecklistVorlage,
ChecklistAusfuehrung,
ChecklistFaelligkeit,
ChecklistOverviewItem,
ChecklistOverviewChecklist,
FahrzeugTyp,
CreateVorlagePayload,
UpdateVorlagePayload,
CreateVorlageItemPayload,
} from '../types/checklist.types';
import type { AusruestungTyp } from '../services/ausruestungTypen';
// ── Helpers ──
@@ -73,6 +87,40 @@ const INTERVALL_LABELS: Record<string, string> = {
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 ──
interface TabPanelProps { children: React.ReactNode; index: number; value: number }
@@ -95,8 +143,8 @@ export default function Checklisten() {
const canManageTemplates = hasPermission('checklisten:manage_templates');
const canExecute = hasPermission('checklisten:execute');
// Tabs: 0=Übersicht, 1=Vorlagen (if perm), 2=Fahrzeugtypen (if perm), 3=Historie
const manageTabs = canManageTemplates ? 2 : 0;
// Tabs: 0=Übersicht, 1=Vorlagen (if perm), 2=Historie
const manageTabs = canManageTemplates ? 1 : 0;
const TAB_COUNT = 2 + manageTabs;
const [tab, setTab] = useState(() => {
@@ -109,15 +157,10 @@ export default function Checklisten() {
}, [searchParams, TAB_COUNT]);
// ── Queries ──
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
queryKey: ['vehicles', 'checklisten-overview'],
queryFn: () => vehiclesApi.getAll(),
});
const { data: overdue = [] } = useQuery({
queryKey: ['checklisten-faellig'],
queryFn: checklistenApi.getOverdue,
refetchInterval: 5 * 60 * 1000,
const { data: overview, isLoading: overviewLoading } = useQuery({
queryKey: ['checklisten', 'overview'],
queryFn: checklistenApi.getOverview,
refetchInterval: 60000,
});
const { data: vorlagen = [], isLoading: vorlagenLoading } = useQuery({
@@ -137,17 +180,9 @@ export default function Checklisten() {
queryFn: () => checklistenApi.getExecutions(),
});
// Build overdue lookup: fahrzeugId -> ChecklistFaelligkeit[]
const overdueByVehicle = overdue.reduce<Record<string, ChecklistFaelligkeit[]>>((acc, f) => {
if (!acc[f.fahrzeug_id]) acc[f.fahrzeug_id] = [];
acc[f.fahrzeug_id].push(f);
return acc;
}, {});
// ── Tab indices ──
const vorlagenTabIdx = canManageTemplates ? 1 : -1;
const typenTabIdx = canManageTemplates ? 2 : -1;
const historieTabIdx = canManageTemplates ? 3 : 1;
const historieTabIdx = canManageTemplates ? 2 : 1;
return (
<DashboardLayout>
@@ -162,73 +197,18 @@ export default function Checklisten() {
>
<Tab label="Übersicht" />
{canManageTemplates && <Tab label="Vorlagen" />}
{canManageTemplates && <Tab label="Fahrzeugtypen" />}
<Tab label="Historie" />
</Tabs>
</Box>
{/* Tab 0: Übersicht */}
<TabPanel value={tab} index={0}>
{vehiclesLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : vehicles.length === 0 ? (
<Typography color="text.secondary">Keine Fahrzeuge vorhanden.</Typography>
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 2 }}>
{vehicles.map((v) => {
const vOverdue = overdueByVehicle[v.id] || [];
return (
<Card key={v.id} variant="outlined">
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="h6">{v.bezeichnung ?? v.kurzname}</Typography>
{vOverdue.length > 0 && (
<Chip
icon={<Warning />}
label={`${vOverdue.length} fä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>
)}
<OverviewTab
overview={overview}
loading={overviewLoading}
canExecute={canExecute}
navigate={navigate}
/>
</TabPanel>
{/* Tab 1: Vorlagen (templates) */}
@@ -245,19 +225,7 @@ export default function Checklisten() {
</TabPanel>
)}
{/* Tab 2: Fahrzeugtypen */}
{canManageTemplates && (
<TabPanel value={tab} index={typenTabIdx}>
<FahrzeugTypenTab
fahrzeugTypen={fahrzeugTypen}
queryClient={queryClient}
showSuccess={showSuccess}
showError={showError}
/>
</TabPanel>
)}
{/* Tab 3: Historie */}
{/* Tab 2: Historie */}
<TabPanel value={tab} index={historieTabIdx}>
<HistorieTab
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
// ══════════════════════════════════════════════════════════════════════════════
@@ -287,9 +362,33 @@ function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSucces
const [editingVorlage, setEditingVorlage] = useState<ChecklistVorlage | null>(null);
const [expandedVorlageId, setExpandedVorlageId] = useState<number | null>(null);
const emptyForm: CreateVorlagePayload = { name: '', fahrzeug_typ_id: undefined, intervall: undefined, intervall_tage: undefined, beschreibung: '', aktiv: true };
// 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);
// 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({
mutationFn: (data: CreateVorlagePayload) => checklistenApi.createVorlage(data),
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'),
});
const openCreate = () => { setEditingVorlage(null); setForm(emptyForm); setDialogOpen(true); };
const openCreate = () => {
setEditingVorlage(null);
setForm(emptyForm);
setAssignmentType('global');
setDialogOpen(true);
};
const openEdit = (v: ChecklistVorlage) => {
setEditingVorlage(v);
setForm({ name: v.name, fahrzeug_typ_id: v.fahrzeug_typ_id, intervall: v.intervall, intervall_tage: v.intervall_tage, beschreibung: v.beschreibung ?? '', aktiv: v.aktiv });
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);
};
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 = () => {
if (!form.name.trim()) return;
const payload = buildPayload();
if (editingVorlage) {
updateMutation.mutate({ id: editingVorlage.id, data: form });
updateMutation.mutate({ id: editingVorlage.id, data: payload });
} 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;
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>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Fahrzeugtyp</TableCell>
<TableCell>Zuordnung</TableCell>
<TableCell>Intervall</TableCell>
<TableCell>Aktiv</TableCell>
<TableCell align="right">Aktionen</TableCell>
@@ -353,7 +497,7 @@ function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSucces
<React.Fragment key={v.id}>
<TableRow hover sx={{ cursor: 'pointer' }} onClick={() => setExpandedVorlageId(expandedVorlageId === v.id ? null : v.id)}>
<TableCell>{v.name}</TableCell>
<TableCell>{v.fahrzeug_typ?.name ?? ''}</TableCell>
<TableCell>{getAssignmentLabel(v)}</TableCell>
<TableCell>
{v.intervall ? INTERVALL_LABELS[v.intervall] || v.intervall : ''}
{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>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField label="Name *" fullWidth value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
<FormControl fullWidth>
<InputLabel>Fahrzeugtyp</InputLabel>
<Select label="Fahrzeugtyp" value={form.fahrzeug_typ_id ?? ''} onChange={(e) => setForm((f) => ({ ...f, fahrzeug_typ_id: e.target.value ? Number(e.target.value) : undefined }))}>
<MenuItem value="">Alle (global)</MenuItem>
{fahrzeugTypen.map((t) => <MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>)}
</Select>
{/* Assignment type */}
<FormControl>
<FormLabel>Zuordnung</FormLabel>
<RadioGroup
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>
{/* 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>
<InputLabel>Intervall</InputLabel>
<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
// ══════════════════════════════════════════════════════════════════════════════
@@ -579,15 +672,18 @@ interface HistorieTabProps {
function HistorieTab({ executions, loading, navigate }: HistorieTabProps) {
const [statusFilter, setStatusFilter] = useState<string>('');
const [vehicleFilter, setVehicleFilter] = useState<string>('');
const [targetFilter, setTargetFilter] = useState<string>('');
const filtered = executions.filter((e) => {
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;
});
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>;
@@ -603,11 +699,11 @@ function HistorieTab({ executions, loading, navigate }: HistorieTabProps) {
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Fahrzeug</InputLabel>
<Select label="Fahrzeug" value={vehicleFilter} onChange={(e) => setVehicleFilter(e.target.value)}>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>Fahrzeug / Ausrüstung</InputLabel>
<Select label="Fahrzeug / Ausrüstung" value={targetFilter} onChange={(e) => setTargetFilter(e.target.value)}>
<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>
</FormControl>
</Box>
@@ -616,7 +712,7 @@ function HistorieTab({ executions, loading, navigate }: HistorieTabProps) {
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Fahrzeug</TableCell>
<TableCell>Fahrzeug / Ausrüstung</TableCell>
<TableCell>Vorlage</TableCell>
<TableCell>Datum</TableCell>
<TableCell>Status</TableCell>
@@ -630,7 +726,7 @@ function HistorieTab({ executions, loading, navigate }: HistorieTabProps) {
) : (
filtered.map((e) => (
<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>{formatDate(e.ausgefuehrt_am ?? e.created_at)}</TableCell>
<TableCell>

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Alert,
Autocomplete,
Box,
Button,
Chip,
@@ -60,6 +61,8 @@ import { vehiclesApi } from '../services/vehicles';
import GermanDateField from '../components/shared/GermanDateField';
import { fromGermanDate } from '../utils/dateInput';
import { equipmentApi } from '../services/equipment';
import { fahrzeugTypenApi } from '../services/fahrzeugTypen';
import type { FahrzeugTyp } from '../types/checklist.types';
import {
FahrzeugDetail as FahrzeugDetailType,
FahrzeugWartungslog,
@@ -187,9 +190,10 @@ interface UebersichtTabProps {
vehicle: FahrzeugDetailType;
onStatusUpdated: () => void;
canChangeStatus: boolean;
canEdit: boolean;
}
const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated, canChangeStatus }) => {
const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated, canChangeStatus, canEdit }) => {
const [statusDialogOpen, setStatusDialogOpen] = useState(false);
const [newStatus, setNewStatus] = useState<FahrzeugStatus>(vehicle.status);
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 [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) =>
s === FahrzeugStatus.AusserDienstWartung || s === FahrzeugStatus.AusserDienstSchaden;
@@ -342,6 +383,50 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
))}
</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 */}
<Typography variant="h6" sx={{ mt: 3, mb: 1.5 }}>
Prüf- und Wartungsfristen
@@ -1047,6 +1132,7 @@ function FahrzeugDetail() {
vehicle={vehicle}
onStatusUpdated={fetchVehicle}
canChangeStatus={canChangeStatus}
canEdit={hasPermission('fahrzeuge:edit')}
/>
</TabPanel>

View 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>
);
}

View 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 });
},
};

View File

@@ -5,6 +5,7 @@ import type {
FahrzeugChecklistItem,
ChecklistAusfuehrung,
ChecklistFaelligkeit,
ChecklistOverviewResponse,
ChecklistVorlageFilter,
ChecklistAusfuehrungFilter,
CreateVorlagePayload,
@@ -17,6 +18,12 @@ import type {
} from '../types/checklist.types';
export const checklistenApi = {
// ── Overview ──
getOverview: async (): Promise<ChecklistOverviewResponse> => {
const r = await api.get('/api/checklisten/overview');
return r.data.data ?? r.data;
},
// ── Vorlagen (Templates) ──
getVorlagen: async (filter?: ChecklistVorlageFilter): Promise<ChecklistVorlage[]> => {
const params = new URLSearchParams();
@@ -86,6 +93,31 @@ export const checklistenApi = {
await api.delete(`/api/checklisten/fahrzeug-items/${id}`);
},
// ── Equipment-specific Items ──
getTemplatesForEquipment: async (ausruestungId: string): Promise<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 ──
getChecklistenForVehicle: async (fahrzeugId: string): Promise<ChecklistVorlage[]> => {
const r = await api.get(`/api/checklisten/fahrzeug/${fahrzeugId}/checklisten`);
@@ -93,8 +125,12 @@ export const checklistenApi = {
},
// ── Executions ──
startExecution: async (fahrzeugId: string, vorlageId: number): Promise<ChecklistAusfuehrung> => {
const r = await api.post('/api/checklisten/ausfuehrungen', { fahrzeug_id: fahrzeugId, vorlage_id: vorlageId });
startExecution: async (vorlageId: number, opts: { fahrzeugId?: string; ausruestungId?: string }): Promise<ChecklistAusfuehrung> => {
const r = await api.post('/api/checklisten/ausfuehrungen', {
vorlage_id: vorlageId,
fahrzeugId: opts.fahrzeugId,
ausruestungId: opts.ausruestungId,
});
return r.data.data;
},

View File

@@ -25,4 +25,13 @@ export const fahrzeugTypenApi = {
delete: async (id: number): Promise<void> => {
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 });
},
};

View File

@@ -15,11 +15,24 @@ export interface ChecklistVorlageItem {
sort_order: number;
}
export interface AusruestungTyp {
id: number;
name: string;
beschreibung?: string;
icon?: string;
}
export interface ChecklistVorlage {
id: number;
name: string;
fahrzeug_typ_id?: number;
fahrzeug_typ?: FahrzeugTyp;
fahrzeug_id?: string;
fahrzeug_name?: string;
ausruestung_id?: string;
ausruestung_name?: string;
ausruestung_typ_id?: number;
ausruestung_typ?: string;
intervall?: 'weekly' | 'monthly' | 'yearly' | 'custom';
intervall_tage?: number;
beschreibung?: string;
@@ -52,8 +65,10 @@ export interface ChecklistAusfuehrungItem {
export interface ChecklistAusfuehrung {
id: string;
fahrzeug_id: string;
fahrzeug_id?: string;
fahrzeug_name?: string;
ausruestung_id?: string;
ausruestung_name?: string;
vorlage_id?: number;
vorlage_name?: string;
status: 'offen' | 'abgeschlossen' | 'unvollstaendig' | 'freigegeben';
@@ -69,8 +84,10 @@ export interface ChecklistAusfuehrung {
}
export interface ChecklistFaelligkeit {
fahrzeug_id: string;
fahrzeug_name: string;
fahrzeug_id?: string;
fahrzeug_name?: string;
ausruestung_id?: string;
ausruestung_name?: string;
vorlage_id: number;
vorlage_name: string;
naechste_faellig_am: string;
@@ -107,6 +124,9 @@ export interface ChecklistAusfuehrungFilter {
export interface CreateVorlagePayload {
name: string;
fahrzeug_typ_id?: number;
fahrzeug_id?: string;
ausruestung_typ_id?: number;
ausruestung_id?: string;
intervall?: 'weekly' | 'monthly' | 'yearly' | 'custom';
intervall_tage?: number;
beschreibung?: string;
@@ -116,6 +136,9 @@ export interface CreateVorlagePayload {
export interface UpdateVorlagePayload {
name?: string;
fahrzeug_typ_id?: number | null;
fahrzeug_id?: string | null;
ausruestung_typ_id?: number | null;
ausruestung_id?: string | null;
intervall?: 'weekly' | 'monthly' | 'yearly' | 'custom' | null;
intervall_tage?: number | null;
beschreibung?: string | null;
@@ -160,3 +183,21 @@ export interface ChecklistWidgetSummary {
overdue: ChecklistFaelligkeit[];
dueSoon: ChecklistFaelligkeit[];
}
export interface ChecklistOverviewChecklist {
vorlage_id: number;
vorlage_name: string;
next_due?: string;
}
export interface ChecklistOverviewItem {
id: string;
name: string;
kurzname?: string;
checklists: ChecklistOverviewChecklist[];
}
export interface ChecklistOverviewResponse {
vehicles: ChecklistOverviewItem[];
equipment: ChecklistOverviewItem[];
}

View File

@@ -28,6 +28,15 @@ export interface AusruestungKategorie {
motorisiert: boolean;
}
// ── Equipment Type (many-to-many) ───────────────────────────────────────────
export interface AusruestungTyp {
id: number;
name: string;
beschreibung?: string;
icon?: string;
}
// ── API Response Shapes ──────────────────────────────────────────────────────
export interface AusruestungListItem {
@@ -52,6 +61,7 @@ export interface AusruestungListItem {
pruefung_tage_bis_faelligkeit: number | null;
created_at: string;
updated_at: string;
typen?: AusruestungTyp[];
}
export interface AusruestungDetail extends AusruestungListItem {