refine vehicle freatures

This commit is contained in:
Matthias Hochmeister
2026-02-28 17:19:18 +01:00
parent 0e81eabda6
commit e2be29c712
17 changed files with 4071 additions and 117 deletions

View File

@@ -60,6 +60,7 @@ import adminRoutes from './routes/admin.routes';
import trainingRoutes from './routes/training.routes';
import vehicleRoutes from './routes/vehicle.routes';
import incidentRoutes from './routes/incident.routes';
import equipmentRoutes from './routes/equipment.routes';
app.use('/api/auth', authRoutes);
app.use('/api/user', userRoutes);
@@ -68,6 +69,7 @@ app.use('/api/admin', adminRoutes);
app.use('/api/training', trainingRoutes);
app.use('/api/vehicles', vehicleRoutes);
app.use('/api/incidents', incidentRoutes);
app.use('/api/equipment', equipmentRoutes);
// 404 handler
app.use(notFoundHandler);

View File

@@ -0,0 +1,318 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import equipmentService from '../services/equipment.service';
import { AusruestungStatus } from '../models/equipment.model';
import logger from '../utils/logger';
// ── UUID validation ───────────────────────────────────────────────────────────
function isValidUUID(s: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
}
// ── Zod Validation Schemas ────────────────────────────────────────────────────
const AusruestungStatusEnum = z.enum([
AusruestungStatus.Einsatzbereit,
AusruestungStatus.Beschaedigt,
AusruestungStatus.InWartung,
AusruestungStatus.AusserDienst,
]);
const isoDate = z.string().regex(
/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/,
'Erwartet ISO-Datum im Format YYYY-MM-DD'
);
const uuidString = z.string().regex(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
'Ungültige UUID'
);
const CreateAusruestungSchema = z.object({
bezeichnung: z.string().min(1).max(200),
kategorie_id: uuidString,
seriennummer: z.string().max(100).optional(),
inventarnummer: z.string().max(50).optional(),
hersteller: z.string().max(150).optional(),
baujahr: z.number().int().min(1950).max(2100).optional(),
status: AusruestungStatusEnum.optional(),
status_bemerkung: z.string().max(2000).optional(),
ist_wichtig: z.boolean().optional(),
fahrzeug_id: uuidString.optional(),
standort: z.string().min(1).max(150).optional(),
pruef_intervall_monate: z.number().int().min(1).optional(),
letzte_pruefung_am: isoDate.optional(),
naechste_pruefung_am: isoDate.optional(),
bemerkung: z.string().max(2000).optional(),
});
const UpdateAusruestungSchema = z.object({
bezeichnung: z.string().min(1).max(200).optional(),
kategorie_id: uuidString.optional(),
seriennummer: z.string().max(100).nullable().optional(),
inventarnummer: z.string().max(50).nullable().optional(),
hersteller: z.string().max(150).nullable().optional(),
baujahr: z.number().int().min(1950).max(2100).nullable().optional(),
status: AusruestungStatusEnum.optional(),
status_bemerkung: z.string().max(2000).nullable().optional(),
ist_wichtig: z.boolean().optional(),
fahrzeug_id: uuidString.nullable().optional(),
standort: z.string().min(1).max(150).optional(),
pruef_intervall_monate: z.number().int().min(1).nullable().optional(),
letzte_pruefung_am: isoDate.nullable().optional(),
naechste_pruefung_am: isoDate.nullable().optional(),
bemerkung: z.string().max(2000).nullable().optional(),
});
const UpdateStatusSchema = z.object({
status: AusruestungStatusEnum,
bemerkung: z.string().max(2000).optional().default(''),
});
const CreateWartungslogSchema = z.object({
datum: isoDate,
art: z.enum(['Prüfung', 'Reparatur', 'Sonstiges']),
beschreibung: z.string().min(1).max(2000),
ergebnis: z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']).optional(),
kosten: z.number().min(0).optional(),
pruefende_stelle: z.string().max(150).optional(),
dokument_url: z.string().url().max(500).refine(
(url) => /^https?:\/\//i.test(url),
'Nur http/https URLs erlaubt'
).optional(),
});
// ── Helper ────────────────────────────────────────────────────────────────────
function getUserId(req: Request): string {
return req.user!.id;
}
// ── Controller ────────────────────────────────────────────────────────────────
class EquipmentController {
async listEquipment(_req: Request, res: Response): Promise<void> {
try {
const equipment = await equipmentService.getAllEquipment();
res.status(200).json({ success: true, data: equipment });
} catch (error) {
logger.error('listEquipment error', { error });
res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht geladen werden' });
}
}
async getEquipment(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-ID' });
return;
}
const equipment = await equipmentService.getEquipmentById(id);
if (!equipment) {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: equipment });
} catch (error) {
logger.error('getEquipment error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht geladen werden' });
}
}
async getByVehicle(req: Request, res: Response): Promise<void> {
try {
const { fahrzeugId } = req.params as Record<string, string>;
if (!isValidUUID(fahrzeugId)) {
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
return;
}
const equipment = await equipmentService.getEquipmentByVehicle(fahrzeugId);
res.status(200).json({ success: true, data: equipment });
} catch (error) {
logger.error('getByVehicle error', { error, fahrzeugId: req.params.fahrzeugId });
res.status(500).json({ success: false, message: 'Ausrüstung für Fahrzeug konnte nicht geladen werden' });
}
}
async getCategories(_req: Request, res: Response): Promise<void> {
try {
const categories = await equipmentService.getCategories();
res.status(200).json({ success: true, data: categories });
} catch (error) {
logger.error('getCategories error', { error });
res.status(500).json({ success: false, message: 'Kategorien konnten nicht geladen werden' });
}
}
async getStats(_req: Request, res: Response): Promise<void> {
try {
const stats = await equipmentService.getEquipmentStats();
res.status(200).json({ success: true, data: stats });
} catch (error) {
logger.error('getStats error', { error });
res.status(500).json({ success: false, message: 'Statistiken konnten nicht geladen werden' });
}
}
async getAlerts(req: Request, res: Response): Promise<void> {
try {
const raw = parseInt((req.query.daysAhead as string) || '30', 10);
if (isNaN(raw) || raw < 0) {
res.status(400).json({ success: false, message: 'Ungültiger daysAhead-Wert' });
return;
}
const daysAhead = Math.min(raw, 365);
const alerts = await equipmentService.getUpcomingInspections(daysAhead);
res.status(200).json({ success: true, data: alerts });
} catch (error) {
logger.error('getAlerts error', { error });
res.status(500).json({ success: false, message: 'Prüfungshinweise konnten nicht geladen werden' });
}
}
async getVehicleWarnings(_req: Request, res: Response): Promise<void> {
try {
const warnings = await equipmentService.getVehicleWarnings();
res.status(200).json({ success: true, data: warnings });
} catch (error) {
logger.error('getVehicleWarnings error', { error });
res.status(500).json({ success: false, message: 'Fahrzeug-Warnungen konnten nicht geladen werden' });
}
}
async createEquipment(req: Request, res: Response): Promise<void> {
try {
const parsed = CreateAusruestungSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const equipment = await equipmentService.createEquipment(parsed.data, getUserId(req));
res.status(201).json({ success: true, data: equipment });
} catch (error) {
logger.error('createEquipment error', { error });
res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht erstellt werden' });
}
}
async updateEquipment(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-ID' });
return;
}
const parsed = UpdateAusruestungSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
if (Object.keys(parsed.data).length === 0) {
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
return;
}
const equipment = await equipmentService.updateEquipment(id, parsed.data, getUserId(req));
if (!equipment) {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: equipment });
} catch (error: any) {
if (error?.message === 'No fields to update') {
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
return;
}
logger.error('updateEquipment error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht aktualisiert werden' });
}
}
async updateStatus(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-ID' });
return;
}
const parsed = UpdateStatusSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
await equipmentService.updateStatus(
id, parsed.data.status, parsed.data.bemerkung, getUserId(req)
);
res.status(200).json({ success: true, message: 'Status aktualisiert' });
} catch (error: any) {
if (error?.message === 'Equipment not found') {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return;
}
logger.error('updateStatus error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Status konnte nicht aktualisiert werden' });
}
}
async deleteEquipment(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-ID' });
return;
}
const deleted = await equipmentService.deleteEquipment(id, getUserId(req));
if (!deleted) {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return;
}
res.status(200).json({ success: true, message: 'Ausrüstung gelöscht' });
} catch (error) {
logger.error('deleteEquipment error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht gelöscht werden' });
}
}
async addWartung(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-ID' });
return;
}
const parsed = CreateWartungslogSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const entry = await equipmentService.addWartungslog(id, parsed.data, getUserId(req));
res.status(201).json({ success: true, data: entry });
} catch (error: any) {
if (error?.message === 'Equipment not found') {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return;
}
logger.error('addWartung error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht gespeichert werden' });
}
}
}
export default new EquipmentController();

View File

@@ -0,0 +1,143 @@
-- Migration 011: Ausrüstungsverwaltung (Equipment Management)
-- Depends on: 001_create_users_table.sql (uuid-ossp extension, users table)
-- 005_create_fahrzeuge.sql (fahrzeuge table)
-- 009_vehicle_soft_delete.sql (soft-delete pattern)
-- ============================================================
-- TABLE: ausruestung_kategorien (Equipment Categories — lookup)
-- ============================================================
CREATE TABLE IF NOT EXISTS ausruestung_kategorien (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL UNIQUE, -- e.g. 'Atemschutzgeräte'
kurzname VARCHAR(30) NOT NULL UNIQUE, -- e.g. 'PA'
sortierung INTEGER NOT NULL DEFAULT 0, -- display order
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================================
-- TABLE: ausruestung (Core Equipment Inventory)
-- ============================================================
CREATE TABLE IF NOT EXISTS ausruestung (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
bezeichnung VARCHAR(200) NOT NULL, -- e.g. 'Dräger PSS 5000'
kategorie_id UUID NOT NULL REFERENCES ausruestung_kategorien(id),
seriennummer VARCHAR(100), -- manufacturer serial
inventarnummer VARCHAR(50), -- internal inventory number
hersteller VARCHAR(150), -- manufacturer
baujahr INTEGER CHECK (baujahr >= 1950 AND baujahr <= 2100),
status VARCHAR(30) NOT NULL DEFAULT 'einsatzbereit'
CHECK (status IN (
'einsatzbereit',
'beschaedigt',
'in_wartung',
'ausser_dienst'
)),
status_bemerkung TEXT, -- free-text status note
ist_wichtig BOOLEAN NOT NULL DEFAULT FALSE, -- drives vehicle card warnings
fahrzeug_id UUID REFERENCES fahrzeuge(id) ON DELETE SET NULL, -- nullable
standort VARCHAR(150) NOT NULL DEFAULT 'Lager', -- used when no fahrzeug
pruef_intervall_monate INTEGER CHECK (pruef_intervall_monate > 0), -- nullable
letzte_pruefung_am DATE,
naechste_pruefung_am DATE,
bemerkung TEXT, -- general notes
deleted_at TIMESTAMPTZ, -- soft-delete
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ausruestung_status
ON ausruestung(status);
CREATE INDEX IF NOT EXISTS idx_ausruestung_kategorie
ON ausruestung(kategorie_id);
CREATE INDEX IF NOT EXISTS idx_ausruestung_fahrzeug
ON ausruestung(fahrzeug_id)
WHERE fahrzeug_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_ausruestung_active
ON ausruestung(id)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_ausruestung_pruefung
ON ausruestung(naechste_pruefung_am)
WHERE naechste_pruefung_am IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_ausruestung_wichtig
ON ausruestung(fahrzeug_id, status)
WHERE ist_wichtig = TRUE AND deleted_at IS NULL;
-- Auto-update updated_at (reuses function from migration 001)
CREATE TRIGGER update_ausruestung_updated_at
BEFORE UPDATE ON ausruestung
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- TABLE: ausruestung_wartungslog (Service/Inspection History)
-- ============================================================
CREATE TABLE IF NOT EXISTS ausruestung_wartungslog (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
ausruestung_id UUID NOT NULL REFERENCES ausruestung(id) ON DELETE CASCADE,
datum DATE NOT NULL,
art VARCHAR(30) NOT NULL
CHECK (art IN (
'Prüfung',
'Reparatur',
'Sonstiges'
)),
beschreibung TEXT NOT NULL,
ergebnis VARCHAR(30)
CHECK (ergebnis IS NULL OR ergebnis IN (
'bestanden',
'bestanden_mit_maengeln',
'nicht_bestanden'
)),
kosten DECIMAL(8,2) CHECK (kosten >= 0),
pruefende_stelle VARCHAR(150), -- e.g. 'Atemschutzwerkstatt Bezirk'
dokument_url VARCHAR(500), -- link to scan/PDF
erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ausruestung_wartung_item
ON ausruestung_wartungslog(ausruestung_id);
CREATE INDEX IF NOT EXISTS idx_ausruestung_wartung_datum
ON ausruestung_wartungslog(datum DESC);
-- ============================================================
-- VIEW: ausruestung_mit_pruefstatus
-- For each active equipment item, joins category and vehicle
-- and computes pruefung_tage_bis_faelligkeit (negative = overdue).
-- The dashboard equipment panel and fleet overview query this view.
-- ============================================================
CREATE OR REPLACE VIEW ausruestung_mit_pruefstatus AS
SELECT
a.*,
k.name AS kategorie_name,
k.kurzname AS kategorie_kurzname,
f.bezeichnung AS fahrzeug_bezeichnung,
f.kurzname AS fahrzeug_kurzname,
CASE
WHEN a.naechste_pruefung_am IS NOT NULL
THEN a.naechste_pruefung_am::date - CURRENT_DATE
ELSE NULL
END AS pruefung_tage_bis_faelligkeit
FROM ausruestung a
JOIN ausruestung_kategorien k ON k.id = a.kategorie_id
LEFT JOIN fahrzeuge f ON f.id = a.fahrzeug_id AND f.deleted_at IS NULL
WHERE a.deleted_at IS NULL;
-- ============================================================
-- SEED DATA: Equipment Categories
-- ============================================================
INSERT INTO ausruestung_kategorien (name, kurzname, sortierung) VALUES
('Atemschutzgeräte', 'PA', 1),
('Pumpen', 'Pumpe', 2),
('Schläuche', 'SL', 3),
('Leitern', 'Leiter', 4),
('Rettungsgeräte', 'RG', 5),
('Messgeräte', 'MG', 6),
('Persönliche Schutzausrüstung', 'PSA', 7),
('Kommunikation', 'Funk', 8),
('Beleuchtung', 'Licht', 9),
('Sonstige', 'Sonst.', 10)
ON CONFLICT (name) DO NOTHING;

View File

@@ -0,0 +1,175 @@
// =============================================================================
// Equipment Management — Domain Model
// =============================================================================
// ── Enums ─────────────────────────────────────────────────────────────────────
export enum AusruestungStatus {
Einsatzbereit = 'einsatzbereit',
Beschaedigt = 'beschaedigt',
InWartung = 'in_wartung',
AusserDienst = 'ausser_dienst',
}
export const AusruestungStatusLabel: Record<AusruestungStatus, string> = {
[AusruestungStatus.Einsatzbereit]: 'Einsatzbereit',
[AusruestungStatus.Beschaedigt]: 'Beschädigt',
[AusruestungStatus.InWartung]: 'In Wartung',
[AusruestungStatus.AusserDienst]: 'Außer Dienst',
};
export type AusruestungWartungslogArt = 'Prüfung' | 'Reparatur' | 'Sonstiges';
// ── Lookup Entity ─────────────────────────────────────────────────────────────
export interface AusruestungKategorie {
id: string;
name: string;
kurzname: string;
sortierung: number;
}
// ── Core Entity ───────────────────────────────────────────────────────────────
export interface Ausruestung {
id: string;
bezeichnung: string;
kategorie_id: string;
seriennummer: string | null;
inventarnummer: string | null;
hersteller: string | null;
baujahr: number | null;
status: AusruestungStatus;
status_bemerkung: string | null;
ist_wichtig: boolean;
fahrzeug_id: string | null;
standort: string;
pruef_intervall_monate: number | null;
letzte_pruefung_am: Date | null;
naechste_pruefung_am: Date | null;
bemerkung: string | null;
deleted_at: Date | null;
created_at: Date;
updated_at: Date;
}
// ── List Item (Grid / Card view) ──────────────────────────────────────────────
export interface AusruestungListItem {
id: string;
bezeichnung: string;
kategorie_id: string;
seriennummer: string | null;
inventarnummer: string | null;
hersteller: string | null;
baujahr: number | null;
status: AusruestungStatus;
status_bemerkung: string | null;
ist_wichtig: boolean;
fahrzeug_id: string | null;
standort: string;
pruef_intervall_monate: number | null;
letzte_pruefung_am: Date | null;
naechste_pruefung_am: Date | null;
bemerkung: string | null;
created_at: Date;
updated_at: Date;
kategorie_name: string;
kategorie_kurzname: string;
fahrzeug_bezeichnung: string | null;
fahrzeug_kurzname: string | null;
pruefung_tage_bis_faelligkeit: number | null;
}
// ── Detail View ───────────────────────────────────────────────────────────────
export interface AusruestungDetail extends AusruestungListItem {
wartungslog: AusruestungWartungslog[];
}
// ── Wartungslog Entity ────────────────────────────────────────────────────────
export interface AusruestungWartungslog {
id: string;
ausruestung_id: string;
datum: Date;
art: AusruestungWartungslogArt;
beschreibung: string;
ergebnis: string | null;
kosten: number | null;
pruefende_stelle: string | null;
dokument_url: string | null;
erfasst_von: string | null;
created_at: Date;
}
// ── Dashboard KPI ─────────────────────────────────────────────────────────────
export interface EquipmentStats {
total: number;
einsatzbereit: number;
beschaedigt: number;
inWartung: number;
ausserDienst: number;
inspectionsDue: number;
inspectionsOverdue: number;
wichtigNichtBereit: number;
}
// ── Vehicle Equipment Warning ─────────────────────────────────────────────────
export interface VehicleEquipmentWarning {
fahrzeug_id: string;
ausruestung_id: string;
bezeichnung: string;
status: AusruestungStatus;
kategorie_name: string;
}
// ── Create / Update DTOs ──────────────────────────────────────────────────────
export interface CreateAusruestungData {
bezeichnung: string;
kategorie_id: string;
seriennummer?: string;
inventarnummer?: string;
hersteller?: string;
baujahr?: number;
status?: AusruestungStatus;
status_bemerkung?: string;
ist_wichtig?: boolean;
fahrzeug_id?: string;
standort?: string;
pruef_intervall_monate?: number;
letzte_pruefung_am?: string;
naechste_pruefung_am?: string;
bemerkung?: string;
}
export interface UpdateAusruestungData {
bezeichnung?: string;
kategorie_id?: string;
seriennummer?: string | null;
inventarnummer?: string | null;
hersteller?: string | null;
baujahr?: number | null;
status?: AusruestungStatus;
status_bemerkung?: string | null;
ist_wichtig?: boolean;
fahrzeug_id?: string | null;
standort?: string;
pruef_intervall_monate?: number | null;
letzte_pruefung_am?: string | null;
naechste_pruefung_am?: string | null;
bemerkung?: string | null;
}
export interface CreateAusruestungWartungslogData {
datum: string;
art: AusruestungWartungslogArt;
beschreibung: string;
ergebnis?: string;
kosten?: number;
pruefende_stelle?: string;
dokument_url?: string;
}

View File

@@ -0,0 +1,32 @@
import { Router } from 'express';
import equipmentController from '../controllers/equipment.controller';
import { authenticate } from '../middleware/auth.middleware';
import { requireGroups } from '../middleware/rbac.middleware';
const ADMIN_GROUPS = ['dashboard_admin'];
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister'];
const router = Router();
// ── Read-only (any authenticated user) ───────────────────────────────────────
router.get('/', authenticate, equipmentController.listEquipment.bind(equipmentController));
router.get('/stats', authenticate, equipmentController.getStats.bind(equipmentController));
router.get('/alerts', authenticate, equipmentController.getAlerts.bind(equipmentController));
router.get('/categories', authenticate, equipmentController.getCategories.bind(equipmentController));
router.get('/vehicle-warnings', authenticate, equipmentController.getVehicleWarnings.bind(equipmentController));
router.get('/vehicle/:fahrzeugId', authenticate, equipmentController.getByVehicle.bind(equipmentController));
router.get('/:id', authenticate, equipmentController.getEquipment.bind(equipmentController));
// ── Write — admin + fahrmeister ──────────────────────────────────────────────
router.post('/', authenticate, requireGroups(WRITE_GROUPS), equipmentController.createEquipment.bind(equipmentController));
router.patch('/:id', authenticate, requireGroups(WRITE_GROUPS), equipmentController.updateEquipment.bind(equipmentController));
router.patch('/:id/status', authenticate, requireGroups(WRITE_GROUPS), equipmentController.updateStatus.bind(equipmentController));
router.post('/:id/wartung', authenticate, requireGroups(WRITE_GROUPS), equipmentController.addWartung.bind(equipmentController));
// ── Delete — admin only ──────────────────────────────────────────────────────
router.delete('/:id', authenticate, requireGroups(ADMIN_GROUPS), equipmentController.deleteEquipment.bind(equipmentController));
export default router;

View File

@@ -0,0 +1,414 @@
import pool from '../config/database';
import logger from '../utils/logger';
import {
Ausruestung,
AusruestungKategorie,
AusruestungListItem,
AusruestungDetail,
AusruestungWartungslog,
CreateAusruestungData,
UpdateAusruestungData,
CreateAusruestungWartungslogData,
AusruestungStatus,
EquipmentStats,
VehicleEquipmentWarning,
} from '../models/equipment.model';
class EquipmentService {
// =========================================================================
// EQUIPMENT OVERVIEW
// =========================================================================
async getAllEquipment(): Promise<AusruestungListItem[]> {
try {
const result = await pool.query(`
SELECT *
FROM ausruestung_mit_pruefstatus
ORDER BY kategorie_kurzname, bezeichnung
`);
return result.rows.map((row) => ({
...row,
pruefung_tage_bis_faelligkeit: row.pruefung_tage_bis_faelligkeit != null
? parseInt(row.pruefung_tage_bis_faelligkeit, 10) : null,
})) as AusruestungListItem[];
} catch (error) {
logger.error('EquipmentService.getAllEquipment failed', { error });
throw new Error('Failed to fetch equipment');
}
}
// =========================================================================
// EQUIPMENT DETAIL
// =========================================================================
async getEquipmentById(id: string): Promise<AusruestungDetail | null> {
try {
const equipmentResult = await pool.query(
`SELECT * FROM ausruestung_mit_pruefstatus WHERE id = $1`,
[id]
);
if (equipmentResult.rows.length === 0) return null;
const row = equipmentResult.rows[0];
const wartungslogResult = await pool.query(
`SELECT * FROM ausruestung_wartungslog
WHERE ausruestung_id = $1
ORDER BY datum DESC, created_at DESC`,
[id]
);
const equipment: AusruestungDetail = {
...row,
pruefung_tage_bis_faelligkeit: row.pruefung_tage_bis_faelligkeit != null
? parseInt(row.pruefung_tage_bis_faelligkeit, 10) : null,
wartungslog: wartungslogResult.rows.map(r => ({
...r,
kosten: r.kosten != null ? Number(r.kosten) : null,
})) as AusruestungWartungslog[],
};
return equipment;
} catch (error) {
logger.error('EquipmentService.getEquipmentById failed', { error, id });
throw new Error('Failed to fetch equipment');
}
}
// =========================================================================
// EQUIPMENT BY VEHICLE
// =========================================================================
async getEquipmentByVehicle(fahrzeugId: string): Promise<AusruestungListItem[]> {
try {
const result = await pool.query(
`SELECT *
FROM ausruestung_mit_pruefstatus
WHERE fahrzeug_id = $1
ORDER BY kategorie_kurzname, bezeichnung`,
[fahrzeugId]
);
return result.rows.map((row) => ({
...row,
pruefung_tage_bis_faelligkeit: row.pruefung_tage_bis_faelligkeit != null
? parseInt(row.pruefung_tage_bis_faelligkeit, 10) : null,
})) as AusruestungListItem[];
} catch (error) {
logger.error('EquipmentService.getEquipmentByVehicle failed', { error, fahrzeugId });
throw new Error('Failed to fetch equipment for vehicle');
}
}
// =========================================================================
// CATEGORIES
// =========================================================================
async getCategories(): Promise<AusruestungKategorie[]> {
try {
const result = await pool.query(
`SELECT * FROM ausruestung_kategorien ORDER BY sortierung`
);
return result.rows as AusruestungKategorie[];
} catch (error) {
logger.error('EquipmentService.getCategories failed', { error });
throw new Error('Failed to fetch equipment categories');
}
}
// =========================================================================
// CRUD
// =========================================================================
async createEquipment(data: CreateAusruestungData, createdBy: string): Promise<Ausruestung> {
try {
const result = await pool.query(
`INSERT INTO ausruestung (
id, bezeichnung, kategorie_id, seriennummer, inventarnummer,
hersteller, baujahr, status, status_bemerkung, ist_wichtig,
fahrzeug_id, standort, pruef_intervall_monate,
letzte_pruefung_am, naechste_pruefung_am, bemerkung
) VALUES (uuid_generate_v4(),$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
RETURNING *`,
[
data.bezeichnung,
data.kategorie_id,
data.seriennummer ?? null,
data.inventarnummer ?? null,
data.hersteller ?? null,
data.baujahr ?? null,
data.status ?? AusruestungStatus.Einsatzbereit,
data.status_bemerkung ?? null,
data.ist_wichtig ?? false,
data.fahrzeug_id ?? null,
data.standort ?? 'Lager',
data.pruef_intervall_monate ?? null,
data.letzte_pruefung_am ?? null,
data.naechste_pruefung_am ?? null,
data.bemerkung ?? null,
]
);
const equipment = result.rows[0] as Ausruestung;
logger.info('Equipment created', { id: equipment.id, by: createdBy });
return equipment;
} catch (error) {
logger.error('EquipmentService.createEquipment failed', { error, createdBy });
throw new Error('Failed to create equipment');
}
}
async updateEquipment(id: string, data: UpdateAusruestungData, updatedBy: string): Promise<Ausruestung | null> {
try {
const fields: string[] = [];
const values: unknown[] = [];
let p = 1;
const addField = (col: string, value: unknown) => {
fields.push(`${col} = $${p++}`);
values.push(value);
};
if (data.bezeichnung !== undefined) addField('bezeichnung', data.bezeichnung);
if (data.kategorie_id !== undefined) addField('kategorie_id', data.kategorie_id);
if (data.seriennummer !== undefined) addField('seriennummer', data.seriennummer);
if (data.inventarnummer !== undefined) addField('inventarnummer', data.inventarnummer);
if (data.hersteller !== undefined) addField('hersteller', data.hersteller);
if (data.baujahr !== undefined) addField('baujahr', data.baujahr);
if (data.status !== undefined) addField('status', data.status);
if (data.status_bemerkung !== undefined) addField('status_bemerkung', data.status_bemerkung);
if (data.ist_wichtig !== undefined) addField('ist_wichtig', data.ist_wichtig);
if (data.fahrzeug_id !== undefined) addField('fahrzeug_id', data.fahrzeug_id);
if (data.standort !== undefined) addField('standort', data.standort);
if (data.pruef_intervall_monate !== undefined) addField('pruef_intervall_monate', data.pruef_intervall_monate);
if (data.letzte_pruefung_am !== undefined) addField('letzte_pruefung_am', data.letzte_pruefung_am);
if (data.naechste_pruefung_am !== undefined) addField('naechste_pruefung_am', data.naechste_pruefung_am);
if (data.bemerkung !== undefined) addField('bemerkung', data.bemerkung);
if (fields.length === 0) {
throw new Error('No fields to update');
}
values.push(id);
const result = await pool.query(
`UPDATE ausruestung SET ${fields.join(', ')} WHERE id = $${p} AND deleted_at IS NULL RETURNING *`,
values
);
if (result.rows.length === 0) {
return null;
}
const equipment = result.rows[0] as Ausruestung;
logger.info('Equipment updated', { id, by: updatedBy });
return equipment;
} catch (error) {
logger.error('EquipmentService.updateEquipment failed', { error, id, updatedBy });
throw error;
}
}
async deleteEquipment(id: string, deletedBy: string): Promise<boolean> {
try {
const result = await pool.query(
`UPDATE ausruestung
SET deleted_at = NOW()
WHERE id = $1 AND deleted_at IS NULL
RETURNING id`,
[id]
);
if (result.rows.length === 0) {
return false;
}
logger.info('Equipment soft-deleted', { id, by: deletedBy });
return true;
} catch (error) {
logger.error('EquipmentService.deleteEquipment failed', { error, id });
throw error;
}
}
// =========================================================================
// STATUS MANAGEMENT
// =========================================================================
async updateStatus(
id: string,
status: AusruestungStatus,
bemerkung: string,
updatedBy: string
): Promise<void> {
try {
const result = await pool.query(
`UPDATE ausruestung
SET status = $1, status_bemerkung = $2, updated_at = NOW()
WHERE id = $3 AND deleted_at IS NULL
RETURNING id`,
[status, bemerkung || null, id]
);
if (result.rows.length === 0) {
throw new Error('Equipment not found');
}
logger.info('Equipment status updated', { id, status, by: updatedBy });
} catch (error) {
logger.error('EquipmentService.updateStatus failed', { error, id });
throw error;
}
}
// =========================================================================
// MAINTENANCE LOG
// =========================================================================
async addWartungslog(
equipmentId: string,
data: CreateAusruestungWartungslogData,
createdBy: string
): Promise<AusruestungWartungslog> {
try {
const check = await pool.query(
`SELECT 1 FROM ausruestung WHERE id = $1 AND deleted_at IS NULL`,
[equipmentId]
);
if (check.rows.length === 0) {
throw new Error('Equipment not found');
}
const result = await pool.query(
`INSERT INTO ausruestung_wartungslog (
ausruestung_id, datum, art, beschreibung,
ergebnis, kosten, pruefende_stelle, dokument_url, erfasst_von
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING *`,
[
equipmentId,
data.datum,
data.art,
data.beschreibung,
data.ergebnis ?? null,
data.kosten ?? null,
data.pruefende_stelle ?? null,
data.dokument_url ?? null,
createdBy,
]
);
const entry = result.rows[0] as AusruestungWartungslog;
logger.info('Equipment wartungslog entry added', { entryId: entry.id, equipmentId, by: createdBy });
return entry;
} catch (error) {
logger.error('EquipmentService.addWartungslog failed', { error, equipmentId });
throw error;
}
}
// =========================================================================
// DASHBOARD KPI
// =========================================================================
async getEquipmentStats(): Promise<EquipmentStats> {
try {
const result = await pool.query(`
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'einsatzbereit') AS einsatzbereit,
COUNT(*) FILTER (WHERE status = 'beschaedigt') AS beschaedigt,
COUNT(*) FILTER (WHERE status = 'in_wartung') AS in_wartung,
COUNT(*) FILTER (WHERE status = 'ausser_dienst') AS ausser_dienst,
COUNT(*) FILTER (
WHERE naechste_pruefung_am IS NOT NULL
AND naechste_pruefung_am::date - CURRENT_DATE BETWEEN 0 AND 30
) AS inspections_due,
COUNT(*) FILTER (
WHERE naechste_pruefung_am IS NOT NULL
AND naechste_pruefung_am::date < CURRENT_DATE
) AS inspections_overdue,
COUNT(*) FILTER (
WHERE ist_wichtig = TRUE
AND status != 'einsatzbereit'
) AS wichtig_nicht_bereit
FROM ausruestung
WHERE deleted_at IS NULL
`);
const row = result.rows[0] ?? {};
return {
total: parseInt(row.total ?? '0', 10),
einsatzbereit: parseInt(row.einsatzbereit ?? '0', 10),
beschaedigt: parseInt(row.beschaedigt ?? '0', 10),
inWartung: parseInt(row.in_wartung ?? '0', 10),
ausserDienst: parseInt(row.ausser_dienst ?? '0', 10),
inspectionsDue: parseInt(row.inspections_due ?? '0', 10),
inspectionsOverdue: parseInt(row.inspections_overdue ?? '0', 10),
wichtigNichtBereit: parseInt(row.wichtig_nicht_bereit ?? '0', 10),
};
} catch (error) {
logger.error('EquipmentService.getEquipmentStats failed', { error });
throw new Error('Failed to fetch equipment stats');
}
}
// =========================================================================
// VEHICLE WARNINGS
// =========================================================================
async getVehicleWarnings(): Promise<VehicleEquipmentWarning[]> {
try {
const result = await pool.query(`
SELECT
a.fahrzeug_id,
a.id AS ausruestung_id,
a.bezeichnung,
a.status,
k.name AS kategorie_name
FROM ausruestung a
JOIN ausruestung_kategorien k ON k.id = a.kategorie_id
WHERE a.ist_wichtig = TRUE
AND a.fahrzeug_id IS NOT NULL
AND a.status != 'einsatzbereit'
AND a.deleted_at IS NULL
ORDER BY a.fahrzeug_id
`);
return result.rows as VehicleEquipmentWarning[];
} catch (error) {
logger.error('EquipmentService.getVehicleWarnings failed', { error });
throw new Error('Failed to fetch vehicle equipment warnings');
}
}
// =========================================================================
// UPCOMING INSPECTIONS
// =========================================================================
async getUpcomingInspections(daysAhead: number = 30): Promise<AusruestungListItem[]> {
try {
const result = await pool.query(
`SELECT *
FROM ausruestung_mit_pruefstatus
WHERE pruefung_tage_bis_faelligkeit IS NOT NULL
AND pruefung_tage_bis_faelligkeit <= $1
ORDER BY pruefung_tage_bis_faelligkeit ASC`,
[daysAhead]
);
return result.rows.map((row) => ({
...row,
pruefung_tage_bis_faelligkeit: row.pruefung_tage_bis_faelligkeit != null
? parseInt(row.pruefung_tage_bis_faelligkeit, 10) : null,
})) as AusruestungListItem[];
} catch (error) {
logger.error('EquipmentService.getUpcomingInspections failed', { error, daysAhead });
throw new Error('Failed to fetch upcoming inspections');
}
}
}
export default new EquipmentService();

View File

@@ -14,6 +14,8 @@ import Fahrzeuge from './pages/Fahrzeuge';
import FahrzeugDetail from './pages/FahrzeugDetail';
import FahrzeugForm from './pages/FahrzeugForm';
import Ausruestung from './pages/Ausruestung';
import AusruestungForm from './pages/AusruestungForm';
import AusruestungDetail from './pages/AusruestungDetail';
import Mitglieder from './pages/Mitglieder';
import MitgliedDetail from './pages/MitgliedDetail';
import Kalender from './pages/Kalender';
@@ -109,6 +111,30 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/ausruestung/neu"
element={
<ProtectedRoute>
<AusruestungForm />
</ProtectedRoute>
}
/>
<Route
path="/ausruestung/:id/bearbeiten"
element={
<ProtectedRoute>
<AusruestungForm />
</ProtectedRoute>
}
/>
<Route
path="/ausruestung/:id"
element={
<ProtectedRoute>
<AusruestungDetail />
</ProtectedRoute>
}
/>
<Route
path="/mitglieder"
element={

View File

@@ -6,7 +6,8 @@ export function usePermissions() {
return {
isAdmin: groups.includes('dashboard_admin'),
canChangeStatus: groups.includes('dashboard_admin') || groups.includes('dashboard_fahrmeister'),
canChangeStatus: groups.includes('dashboard_admin') || groups.includes('dashboard_fahrmeister'),
canManageEquipment: groups.includes('dashboard_admin') || groups.includes('dashboard_fahrmeister'),
groups,
};
}

View File

@@ -1,66 +1,478 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import {
Container,
Typography,
Card,
CardContent,
Alert,
Box,
Button,
Card,
CardActionArea,
CardContent,
Chip,
CircularProgress,
Container,
Fab,
FormControl,
FormControlLabel,
Grid,
InputAdornment,
InputLabel,
MenuItem,
Select,
Switch,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import { Build } from '@mui/icons-material';
import {
Add,
Build,
CheckCircle,
Error as ErrorIcon,
LinkRounded,
PauseCircle,
RemoveCircle,
Search,
Star,
Warning,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { equipmentApi } from '../services/equipment';
import {
AusruestungListItem,
AusruestungKategorie,
AusruestungStatus,
AusruestungStatusLabel,
EquipmentStats,
} from '../types/equipment.types';
import { usePermissions } from '../hooks/usePermissions';
// ── Status chip config ────────────────────────────────────────────────────────
const STATUS_CONFIG: Record<
AusruestungStatus,
{ color: 'success' | 'warning' | 'error' | 'default'; icon: React.ReactElement }
> = {
[AusruestungStatus.Einsatzbereit]: { color: 'success', icon: <CheckCircle fontSize="small" /> },
[AusruestungStatus.Beschaedigt]: { color: 'error', icon: <ErrorIcon fontSize="small" /> },
[AusruestungStatus.InWartung]: { color: 'warning', icon: <PauseCircle fontSize="small" /> },
[AusruestungStatus.AusserDienst]: { color: 'default', icon: <RemoveCircle fontSize="small" /> },
};
// ── Inspection badge helpers ──────────────────────────────────────────────────
type InspBadgeColor = 'success' | 'warning' | 'error' | 'default';
function inspBadgeColor(tage: number | null): InspBadgeColor {
if (tage === null) return 'default';
if (tage < 0) return 'error';
if (tage <= 30) return 'warning';
return 'success';
}
function inspBadgeLabel(tage: number | null, faelligAm: string | null): string {
if (faelligAm === null) return '';
const date = new Date(faelligAm).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: '2-digit',
});
if (tage === null) return `Prüfung: ${date}`;
if (tage < 0) return `ÜBERFÄLLIG (${date})`;
if (tage === 0) return `Prüfung: heute`;
return `Prüfung: ${date}`;
}
function inspTooltipTitle(tage: number | null, faelligAm: string | null): string {
if (!faelligAm) return 'Keine Prüfung geplant';
const date = new Date(faelligAm).toLocaleDateString('de-DE');
if (tage !== null && tage < 0) {
return `Prüfung seit ${Math.abs(tage)} Tagen überfällig!`;
}
if (tage !== null && tage === 0) {
return 'Prüfung heute fällig';
}
if (tage !== null) {
return `Nächste Prüfung am ${date} (in ${tage} Tagen)`;
}
return `Nächste Prüfung am ${date}`;
}
// ── Equipment Card ────────────────────────────────────────────────────────────
interface EquipmentCardProps {
item: AusruestungListItem;
onClick: (id: string) => void;
}
const EquipmentCard: React.FC<EquipmentCardProps> = ({ item, onClick }) => {
const status = item.status as AusruestungStatus;
const statusCfg = STATUS_CONFIG[status] ?? STATUS_CONFIG[AusruestungStatus.Einsatzbereit];
const isBeschaedigt = status === AusruestungStatus.Beschaedigt;
const pruefungLabel = inspBadgeLabel(item.pruefung_tage_bis_faelligkeit, item.naechste_pruefung_am);
const pruefungColor = inspBadgeColor(item.pruefung_tage_bis_faelligkeit);
const pruefungTooltip = inspTooltipTitle(item.pruefung_tage_bis_faelligkeit, item.naechste_pruefung_am);
return (
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
border: isBeschaedigt ? '2px solid' : undefined,
borderColor: isBeschaedigt ? 'error.main' : undefined,
position: 'relative',
}}
>
{item.ist_wichtig && (
<Tooltip title="Wichtige Ausrüstung">
<Star
sx={{ position: 'absolute', top: 8, right: 8, zIndex: 1, color: 'warning.main' }}
/>
</Tooltip>
)}
<CardActionArea
onClick={() => onClick(item.id)}
sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}
>
<Box
sx={{
height: 80,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'action.hover',
}}
>
<Build sx={{ fontSize: 48, color: 'text.disabled' }} />
</Box>
<CardContent sx={{ flexGrow: 1, pb: '8px !important' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 0.5 }}>
<Box sx={{ minWidth: 0 }}>
<Typography variant="h6" component="div" lineHeight={1.2} noWrap>
{item.bezeichnung}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
<Chip
label={item.kategorie_kurzname}
size="small"
variant="outlined"
sx={{ fontSize: '0.7rem' }}
/>
</Box>
</Box>
</Box>
{/* Location */}
<Box sx={{ mt: 1 }}>
{item.fahrzeug_bezeichnung ? (
<Typography variant="body2" color="text.secondary" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<LinkRounded fontSize="small" />
{item.fahrzeug_bezeichnung}
{item.fahrzeug_kurzname && ` (${item.fahrzeug_kurzname})`}
</Typography>
) : (
<Typography variant="body2" color="text.secondary">
{item.standort}
</Typography>
)}
</Box>
{/* Serial number */}
{item.seriennummer && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, fontSize: '0.75rem' }}>
SN: {item.seriennummer}
</Typography>
)}
{/* Status chip */}
<Box sx={{ mt: 1, mb: 0.5 }}>
<Chip
icon={statusCfg.icon}
label={AusruestungStatusLabel[status]}
color={statusCfg.color}
size="small"
variant="outlined"
/>
</Box>
{/* Inspection badge */}
{pruefungLabel && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.5 }}>
<Tooltip title={pruefungTooltip}>
<Chip
size="small"
label={pruefungLabel}
color={pruefungColor}
variant={pruefungColor === 'default' ? 'outlined' : 'filled'}
icon={item.pruefung_tage_bis_faelligkeit !== null && item.pruefung_tage_bis_faelligkeit < 0
? <Warning fontSize="small" />
: undefined}
sx={{ fontSize: '0.7rem' }}
/>
</Tooltip>
</Box>
)}
</CardContent>
</CardActionArea>
</Card>
);
};
// ── Main Page ─────────────────────────────────────────────────────────────────
function Ausruestung() {
const navigate = useNavigate();
const { canManageEquipment } = usePermissions();
// Data state
const [equipment, setEquipment] = useState<AusruestungListItem[]>([]);
const [categories, setCategories] = useState<AusruestungKategorie[]>([]);
const [stats, setStats] = useState<EquipmentStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filter state
const [search, setSearch] = useState('');
const [selectedCategory, setSelectedCategory] = useState('');
const [selectedStatus, setSelectedStatus] = useState('');
const [nurWichtige, setNurWichtige] = useState(false);
const [pruefungFaellig, setPruefungFaellig] = useState(false);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const [equipmentData, categoriesData, statsData] = await Promise.all([
equipmentApi.getAll(),
equipmentApi.getCategories(),
equipmentApi.getStats(),
]);
setEquipment(equipmentData);
setCategories(categoriesData);
setStats(statsData);
} catch {
setError('Ausrüstung konnte nicht geladen werden. Bitte versuchen Sie es erneut.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
// Client-side filtering
const filtered = useMemo(() => {
return equipment.filter((item) => {
// Text search
if (search.trim()) {
const q = search.toLowerCase();
const matches =
item.bezeichnung.toLowerCase().includes(q) ||
(item.seriennummer?.toLowerCase().includes(q) ?? false) ||
(item.inventarnummer?.toLowerCase().includes(q) ?? false) ||
(item.hersteller?.toLowerCase().includes(q) ?? false);
if (!matches) return false;
}
// Category filter
if (selectedCategory && item.kategorie_id !== selectedCategory) {
return false;
}
// Status filter
if (selectedStatus && item.status !== selectedStatus) {
return false;
}
// Nur wichtige
if (nurWichtige && !item.ist_wichtig) {
return false;
}
// Prüfung fällig (within 30 days or overdue)
if (pruefungFaellig) {
if (item.pruefung_tage_bis_faelligkeit === null || item.pruefung_tage_bis_faelligkeit > 30) {
return false;
}
}
return true;
});
}, [equipment, search, selectedCategory, selectedStatus, nurWichtige, pruefungFaellig]);
const hasOverdue = equipment.some(
(item) => item.pruefung_tage_bis_faelligkeit !== null && item.pruefung_tage_bis_faelligkeit < 0
);
return (
<DashboardLayout>
<Container maxWidth="lg">
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
Ausrüstungsverwaltung
</Typography>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Build color="primary" sx={{ fontSize: 48, mr: 2 }} />
<Box>
<Typography variant="h6">Ausrüstung</Typography>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box>
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
Ausrüstungsverwaltung
</Typography>
{!loading && stats && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 0.5 }}>
<Typography variant="body2" color="text.secondary">
Diese Funktion wird in Kürze verfügbar sein
{stats.total} Gesamt
</Typography>
<Typography variant="body2" color="text.secondary">{'·'}</Typography>
<Typography variant="body2" color="success.main" fontWeight={600}>
{stats.einsatzbereit} Einsatzbereit
</Typography>
<Typography variant="body2" color="text.secondary">{'·'}</Typography>
<Typography variant="body2" color="error.main" fontWeight={600}>
{stats.beschaedigt} Beschädigt
</Typography>
<Typography variant="body2" color="text.secondary">{'·'}</Typography>
<Typography variant="body2" color={stats.inspectionsDue > 0 ? 'warning.main' : 'text.secondary'} fontWeight={stats.inspectionsDue > 0 ? 600 : 400}>
{stats.inspectionsDue + stats.inspectionsOverdue} Prüfung fällig
</Typography>
</Box>
</Box>
<Box sx={{ mt: 3 }}>
<Typography variant="body1" color="text.secondary" paragraph>
Geplante Features:
</Typography>
<ul>
<li>
<Typography variant="body2" color="text.secondary">
Inventarverwaltung
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Wartungsprüfungen und -protokolle
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Prüffristen und Erinnerungen
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Schutzausrüstung (PSA)
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Atemschutzgeräte und -wartung
</Typography>
</li>
</ul>
</Box>
</CardContent>
</Card>
)}
</Box>
</Box>
{/* Overdue alert */}
{hasOverdue && (
<Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}>
<strong>Achtung:</strong> Mindestens eine Ausrüstung hat eine überfällige Prüfungsfrist.
</Alert>
)}
{/* Filter controls */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 3, alignItems: 'center' }}>
<TextField
placeholder="Suchen (Bezeichnung, Seriennr., Inventarnr., Hersteller...)"
value={search}
onChange={(e) => setSearch(e.target.value)}
size="small"
sx={{ minWidth: 280, flexGrow: 1, maxWidth: 480 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search />
</InputAdornment>
),
}}
/>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Kategorie</InputLabel>
<Select
value={selectedCategory}
label="Kategorie"
onChange={(e) => setSelectedCategory(e.target.value)}
>
<MenuItem value="">Alle Kategorien</MenuItem>
{categories.map((cat) => (
<MenuItem key={cat.id} value={cat.id}>
{cat.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Status</InputLabel>
<Select
value={selectedStatus}
label="Status"
onChange={(e) => setSelectedStatus(e.target.value)}
>
<MenuItem value="">Alle Status</MenuItem>
{Object.values(AusruestungStatus).map((s) => (
<MenuItem key={s} value={s}>
{AusruestungStatusLabel[s]}
</MenuItem>
))}
</Select>
</FormControl>
<FormControlLabel
control={
<Switch
checked={nurWichtige}
onChange={(e) => setNurWichtige(e.target.checked)}
size="small"
/>
}
label={<Typography variant="body2">Nur wichtige</Typography>}
/>
<FormControlLabel
control={
<Switch
checked={pruefungFaellig}
onChange={(e) => setPruefungFaellig(e.target.checked)}
size="small"
/>
}
label={<Typography variant="body2">Prüfung fällig</Typography>}
/>
</Box>
{/* Loading state */}
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
)}
{/* Error state */}
{!loading && error && (
<Alert
severity="error"
sx={{ mb: 2 }}
action={
<Button color="inherit" size="small" onClick={fetchData}>
Erneut versuchen
</Button>
}
>
{error}
</Alert>
)}
{/* Empty states */}
{!loading && !error && filtered.length === 0 && (
<Box sx={{ textAlign: 'center', py: 8 }}>
<Build sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
<Typography variant="h6" color="text.secondary">
{equipment.length === 0
? 'Keine Ausrüstung vorhanden'
: 'Keine Ausrüstung gefunden'}
</Typography>
</Box>
)}
{/* Equipment grid */}
{!loading && !error && filtered.length > 0 && (
<Grid container spacing={3}>
{filtered.map((item) => (
<Grid item key={item.id} xs={12} sm={6} md={4} lg={3}>
<EquipmentCard
item={item}
onClick={(id) => navigate(`/ausruestung/${id}`)}
/>
</Grid>
))}
</Grid>
)}
{/* FAB for adding new equipment */}
{canManageEquipment && (
<Fab
color="primary"
aria-label="Ausrüstung hinzufügen"
sx={{ position: 'fixed', bottom: 32, right: 32 }}
onClick={() => navigate('/ausruestung/neu')}
>
<Add />
</Fab>
)}
</Container>
</DashboardLayout>
);

View File

@@ -0,0 +1,762 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
Fab,
FormControl,
Grid,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Tab,
Tabs,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import {
Add,
ArrowBack,
Build,
CheckCircle,
DeleteOutline,
Edit,
Error as ErrorIcon,
MoreHoriz,
PauseCircle,
RemoveCircle,
Star,
Verified,
Warning,
} from '@mui/icons-material';
import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { equipmentApi } from '../services/equipment';
import {
AusruestungDetail,
AusruestungWartungslog,
AusruestungWartungslogArt,
AusruestungStatus,
AusruestungStatusLabel,
UpdateAusruestungStatusPayload,
CreateAusruestungWartungslogPayload,
} from '../types/equipment.types';
import { usePermissions } from '../hooks/usePermissions';
import { useNotification } from '../contexts/NotificationContext';
// -- Tab Panel ----------------------------------------------------------------
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => (
<div role="tabpanel" hidden={value !== index}>
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
</div>
);
// -- Status config ------------------------------------------------------------
const STATUS_ICONS: Record<AusruestungStatus, React.ReactElement> = {
[AusruestungStatus.Einsatzbereit]: <CheckCircle color="success" />,
[AusruestungStatus.Beschaedigt]: <ErrorIcon color="error" />,
[AusruestungStatus.InWartung]: <PauseCircle color="warning" />,
[AusruestungStatus.AusserDienst]: <RemoveCircle color="action" />,
};
const STATUS_CHIP_COLOR: Record<AusruestungStatus, 'success' | 'error' | 'warning' | 'default'> = {
[AusruestungStatus.Einsatzbereit]: 'success',
[AusruestungStatus.Beschaedigt]: 'error',
[AusruestungStatus.InWartung]: 'warning',
[AusruestungStatus.AusserDienst]: 'default',
};
// -- Date helpers -------------------------------------------------------------
function fmtDate(iso: string | null | undefined): string {
if (!iso) return '---';
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
});
}
// -- Wartungslog Art config ---------------------------------------------------
const WARTUNG_ART_CHIP_COLOR: Record<AusruestungWartungslogArt, 'info' | 'warning' | 'default'> = {
'Prüfung': 'info',
'Reparatur': 'warning',
'Sonstiges': 'default',
};
const WARTUNG_ART_ICONS: Record<string, React.ReactElement> = {
'Prüfung': <Verified color="info" />,
'Reparatur': <Build color="warning" />,
'Sonstiges': <MoreHoriz color="action" />,
default: <Build color="action" />,
};
const ERGEBNIS_CHIP_COLOR: Record<string, 'success' | 'warning' | 'error'> = {
bestanden: 'success',
bestanden_mit_maengeln: 'warning',
nicht_bestanden: 'error',
};
const ERGEBNIS_LABEL: Record<string, string> = {
bestanden: 'Bestanden',
bestanden_mit_maengeln: 'Bestanden (mit Mängeln)',
nicht_bestanden: 'Nicht bestanden',
};
// -- Uebersicht Tab -----------------------------------------------------------
interface UebersichtTabProps {
equipment: AusruestungDetail;
onStatusUpdated: () => void;
canChangeStatus: boolean;
}
const UebersichtTab: React.FC<UebersichtTabProps> = ({ equipment, onStatusUpdated, canChangeStatus }) => {
const [statusDialogOpen, setStatusDialogOpen] = useState(false);
const [newStatus, setNewStatus] = useState<AusruestungStatus>(equipment.status);
const [bemerkung, setBemerkung] = useState(equipment.status_bemerkung ?? '');
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const openDialog = () => {
setNewStatus(equipment.status);
setBemerkung(equipment.status_bemerkung ?? '');
setSaveError(null);
setStatusDialogOpen(true);
};
const closeDialog = () => {
setSaveError(null);
setStatusDialogOpen(false);
};
const handleSaveStatus = async () => {
try {
setSaving(true);
setSaveError(null);
const payload: UpdateAusruestungStatusPayload = { status: newStatus, bemerkung };
await equipmentApi.updateStatus(equipment.id, payload);
setStatusDialogOpen(false);
onStatusUpdated();
} catch {
setSaveError('Status konnte nicht gespeichert werden.');
} finally {
setSaving(false);
}
};
const isBeschaedigt = equipment.status === AusruestungStatus.Beschaedigt;
// Inspection deadline
const pruefTage = equipment.pruefung_tage_bis_faelligkeit;
// Data grid fields
const dataFields: { label: string; value: React.ReactNode }[] = [
{ label: 'Kategorie', value: equipment.kategorie_name },
{ label: 'Seriennummer', value: equipment.seriennummer ?? '---' },
{ label: 'Inventarnummer', value: equipment.inventarnummer ?? '---' },
{ label: 'Hersteller', value: equipment.hersteller ?? '---' },
{ label: 'Baujahr', value: equipment.baujahr ?? '---' },
{
label: 'Fahrzeug',
value: equipment.fahrzeug_id ? (
<Typography
component={RouterLink}
to={`/fahrzeuge/${equipment.fahrzeug_id}`}
variant="body1"
sx={{ color: 'primary.main', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}
>
{equipment.fahrzeug_bezeichnung}
</Typography>
) : '---',
},
...(!equipment.fahrzeug_id ? [{ label: 'Standort', value: equipment.standort || '---' }] : []),
{
label: 'Wichtig',
value: equipment.ist_wichtig ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Star fontSize="small" color="warning" /> Ja
</Box>
) : 'Nein',
},
{
label: 'Prüfintervall',
value: equipment.pruef_intervall_monate
? `${equipment.pruef_intervall_monate} Monate`
: '---',
},
{ label: 'Letzte Prüfung', value: fmtDate(equipment.letzte_pruefung_am) },
{
label: 'Nächste Prüfung',
value: equipment.naechste_pruefung_am ? (
<Box>
<Typography variant="body1">{fmtDate(equipment.naechste_pruefung_am)}</Typography>
{pruefTage !== null && pruefTage < 0 && (
<Chip
size="small"
color="error"
icon={<Warning fontSize="small" />}
label={`${Math.abs(pruefTage)} Tage überfällig`}
sx={{ mt: 0.5 }}
/>
)}
{pruefTage !== null && pruefTage >= 0 && pruefTage <= 30 && (
<Chip
size="small"
color="warning"
label={`in ${pruefTage} Tagen`}
sx={{ mt: 0.5 }}
/>
)}
{pruefTage !== null && pruefTage > 30 && (
<Chip
size="small"
color="success"
label={`in ${pruefTage} Tagen`}
sx={{ mt: 0.5 }}
/>
)}
</Box>
) : (
<Chip size="small" color="default" label="Nicht festgelegt" />
),
},
{ label: 'Bemerkung', value: equipment.bemerkung ?? '---' },
];
return (
<Box>
{isBeschaedigt && (
<Alert severity="error" icon={<ErrorIcon />} sx={{ mb: 2 }}>
<strong>Beschädigt</strong> --- dieses Gerät ist nicht einsatzbereit.
{equipment.status_bemerkung && ` Bemerkung: ${equipment.status_bemerkung}`}
</Alert>
)}
{/* Status panel */}
<Paper variant="outlined" sx={{ p: 2, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
{STATUS_ICONS[equipment.status]}
<Box>
<Typography variant="subtitle1" fontWeight={600}>Aktueller Status</Typography>
<Chip
label={AusruestungStatusLabel[equipment.status]}
color={STATUS_CHIP_COLOR[equipment.status]}
size="small"
/>
{equipment.status_bemerkung && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{equipment.status_bemerkung}
</Typography>
)}
</Box>
</Box>
{canChangeStatus && (
<Button variant="outlined" size="small" onClick={openDialog}>
Status ändern
</Button>
)}
</Box>
</Paper>
{/* Equipment data grid */}
<Grid container spacing={2}>
{dataFields.map(({ label, value }) => (
<Grid item xs={12} sm={6} md={4} key={label}>
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
{label}
</Typography>
{typeof value === 'string' || typeof value === 'number' ? (
<Typography variant="body1">{value}</Typography>
) : (
<Box>{value}</Box>
)}
</Grid>
))}
</Grid>
{/* Status change dialog */}
<Dialog open={statusDialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
<DialogTitle>Gerätestatus ändern</DialogTitle>
<DialogContent>
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
<FormControl fullWidth sx={{ mb: 2, mt: 1 }}>
<InputLabel id="status-select-label">Neuer Status</InputLabel>
<Select
labelId="status-select-label"
label="Neuer Status"
value={newStatus}
onChange={(e) => setNewStatus(e.target.value as AusruestungStatus)}
>
{Object.values(AusruestungStatus).map((s) => (
<MenuItem key={s} value={s}>{AusruestungStatusLabel[s]}</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="Bemerkung (optional)"
fullWidth
multiline
rows={3}
value={bemerkung}
onChange={(e) => setBemerkung(e.target.value)}
placeholder="z.B. Gerät zur Reparatur eingeschickt, voraussichtlich ab 01.03. wieder einsatzbereit"
/>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSaveStatus}
disabled={saving}
startIcon={saving ? <CircularProgress size={16} /> : undefined}
>
Speichern
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
// -- Wartung Tab --------------------------------------------------------------
interface WartungTabProps {
equipmentId: string;
wartungslog: AusruestungWartungslog[];
onAdded: () => void;
canWrite: boolean;
}
const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdded, canWrite }) => {
const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const emptyForm: CreateAusruestungWartungslogPayload = {
datum: '',
art: 'Prüfung' as AusruestungWartungslogArt,
beschreibung: '',
ergebnis: undefined,
kosten: undefined,
pruefende_stelle: undefined,
};
const [form, setForm] = useState<CreateAusruestungWartungslogPayload>(emptyForm);
const handleSubmit = async () => {
if (!form.datum || !form.art || !form.beschreibung.trim()) {
setSaveError('Datum, Art und Beschreibung sind erforderlich.');
return;
}
try {
setSaving(true);
setSaveError(null);
await equipmentApi.addWartungslog(equipmentId, {
...form,
pruefende_stelle: form.pruefende_stelle || undefined,
ergebnis: form.ergebnis || undefined,
});
setDialogOpen(false);
setForm(emptyForm);
onAdded();
} catch {
setSaveError('Wartungseintrag konnte nicht gespeichert werden.');
} finally {
setSaving(false);
}
};
// Sort wartungslog by datum DESC
const sorted = [...wartungslog].sort(
(a, b) => new Date(b.datum).getTime() - new Date(a.datum).getTime()
);
return (
<Box>
{sorted.length === 0 ? (
<Typography color="text.secondary">Keine Wartungseinträge vorhanden.</Typography>
) : (
<Stack divider={<Divider />} spacing={0}>
{sorted.map((entry) => {
const artIcon = WARTUNG_ART_ICONS[entry.art ?? ''] ?? WARTUNG_ART_ICONS.default;
const ergebnisColor = entry.ergebnis ? ERGEBNIS_CHIP_COLOR[entry.ergebnis] : undefined;
const ergebnisLabel = entry.ergebnis ? ERGEBNIS_LABEL[entry.ergebnis] : undefined;
return (
<Box key={entry.id} sx={{ py: 2, display: 'flex', gap: 2, alignItems: 'flex-start' }}>
<Box sx={{ mt: 0.25 }}>{artIcon}</Box>
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25, flexWrap: 'wrap' }}>
<Typography variant="subtitle2">{fmtDate(entry.datum)}</Typography>
{entry.art && (
<Chip
label={entry.art}
size="small"
color={WARTUNG_ART_CHIP_COLOR[entry.art] ?? 'default'}
variant="outlined"
/>
)}
{ergebnisLabel && ergebnisColor && (
<Chip
label={ergebnisLabel}
size="small"
color={ergebnisColor}
/>
)}
</Box>
<Typography variant="body2">{entry.beschreibung}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
{[
entry.kosten != null && `${entry.kosten.toFixed(2)} EUR`,
entry.pruefende_stelle && entry.pruefende_stelle,
].filter(Boolean).join(' · ')}
</Typography>
</Box>
</Box>
);
})}
</Stack>
)}
{canWrite && (
<Fab
color="primary"
size="small"
aria-label="Wartung eintragen"
sx={{ position: 'fixed', bottom: 32, right: 32 }}
onClick={() => { setForm(emptyForm); setSaveError(null); setDialogOpen(true); }}
>
<Add />
</Fab>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Wartung / Prüfung eintragen</DialogTitle>
<DialogContent>
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
<Grid container spacing={2} sx={{ mt: 0.5 }}>
<Grid item xs={12} sm={6}>
<TextField
label="Datum *"
type="date"
fullWidth
value={form.datum}
onChange={(e) => setForm((f) => ({ ...f, datum: e.target.value }))}
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Art *</InputLabel>
<Select
label="Art *"
value={form.art ?? ''}
onChange={(e) =>
setForm((f) => ({
...f,
art: (e.target.value || undefined) as AusruestungWartungslogArt,
}))
}
>
<MenuItem value="">--- Bitte wählen ---</MenuItem>
{(['Prüfung', 'Reparatur', 'Sonstiges'] as AusruestungWartungslogArt[]).map((a) => (
<MenuItem key={a} value={a}>{a}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12}>
<TextField
label="Beschreibung *"
fullWidth
multiline
rows={3}
value={form.beschreibung}
onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))}
/>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Ergebnis</InputLabel>
<Select
label="Ergebnis"
value={form.ergebnis ?? ''}
onChange={(e) =>
setForm((f) => ({
...f,
ergebnis: e.target.value || undefined,
}))
}
>
<MenuItem value="">--- Kein Ergebnis ---</MenuItem>
<MenuItem value="bestanden">Bestanden</MenuItem>
<MenuItem value="bestanden_mit_maengeln">Bestanden (mit Mängeln)</MenuItem>
<MenuItem value="nicht_bestanden">Nicht bestanden</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Kosten (EUR)"
type="number"
fullWidth
value={form.kosten ?? ''}
onChange={(e) =>
setForm((f) => ({
...f,
kosten: e.target.value ? Number(e.target.value) : undefined,
}))
}
inputProps={{ min: 0, step: 0.01 }}
/>
</Grid>
<Grid item xs={12}>
<TextField
label="Prüfende Stelle"
fullWidth
value={form.pruefende_stelle ?? ''}
onChange={(e) => setForm((f) => ({ ...f, pruefende_stelle: e.target.value }))}
placeholder="Name der prüfenden Stelle oder Person"
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={saving}
startIcon={saving ? <CircularProgress size={16} /> : undefined}
>
Speichern
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
// -- Main Page ----------------------------------------------------------------
function AusruestungDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAdmin, canChangeStatus } = usePermissions();
const notification = useNotification();
const [equipment, setEquipment] = useState<AusruestungDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState(0);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const fetchEquipment = useCallback(async () => {
if (!id) return;
try {
setLoading(true);
setError(null);
const data = await equipmentApi.getById(id);
setEquipment(data);
} catch {
setError('Gerät konnte nicht geladen werden.');
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => { fetchEquipment(); }, [fetchEquipment]);
const handleDelete = async () => {
if (!id) return;
try {
setDeleteLoading(true);
await equipmentApi.delete(id);
notification.showSuccess('Gerät wurde erfolgreich gelöscht.');
navigate('/ausruestung');
} catch {
notification.showError('Gerät konnte nicht gelöscht werden.');
setDeleteDialogOpen(false);
setDeleteLoading(false);
}
};
if (loading) {
return (
<DashboardLayout>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
</DashboardLayout>
);
}
if (error || !equipment) {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Alert severity="error">{error ?? 'Gerät nicht gefunden.'}</Alert>
<Button startIcon={<ArrowBack />} onClick={() => navigate('/ausruestung')} sx={{ mt: 2 }}>
Zurück zur Übersicht
</Button>
</Container>
</DashboardLayout>
);
}
const hasOverdue =
equipment.pruefung_tage_bis_faelligkeit !== null &&
equipment.pruefung_tage_bis_faelligkeit < 0;
const subtitle = [
equipment.kategorie_name,
equipment.seriennummer ? `SN: ${equipment.seriennummer}` : null,
].filter(Boolean).join(' · ');
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', gap: 2, mb: 1 }}>
<Build sx={{ fontSize: 36, color: 'text.secondary' }} />
<Box>
<Typography variant="h4" component="h1">
{equipment.bezeichnung}
</Typography>
{subtitle && (
<Typography variant="subtitle1" color="text.secondary">
{subtitle}
</Typography>
)}
</Box>
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip
icon={STATUS_ICONS[equipment.status]}
label={AusruestungStatusLabel[equipment.status]}
color={STATUS_CHIP_COLOR[equipment.status]}
/>
{canChangeStatus && (
<Tooltip title="Gerät bearbeiten">
<IconButton
size="small"
onClick={() => navigate(`/ausruestung/${equipment.id}/bearbeiten`)}
aria-label="Gerät bearbeiten"
>
<Edit />
</IconButton>
</Tooltip>
)}
{isAdmin && (
<Tooltip title="Gerät löschen">
<IconButton
size="small"
color="error"
onClick={() => setDeleteDialogOpen(true)}
aria-label="Gerät löschen"
>
<DeleteOutline />
</IconButton>
</Tooltip>
)}
</Box>
</Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mt: 2 }}>
<Tabs
value={activeTab}
onChange={(_, v) => setActiveTab(v)}
aria-label="Ausrüstung Detailansicht"
>
<Tab label="Übersicht" />
<Tab
label={
hasOverdue
? <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Wartung <Warning color="error" fontSize="small" />
</Box>
: 'Wartung'
}
/>
</Tabs>
</Box>
<TabPanel value={activeTab} index={0}>
<UebersichtTab
equipment={equipment}
onStatusUpdated={fetchEquipment}
canChangeStatus={canChangeStatus}
/>
</TabPanel>
<TabPanel value={activeTab} index={1}>
<WartungTab
equipmentId={equipment.id}
wartungslog={equipment.wartungslog ?? []}
onAdded={fetchEquipment}
canWrite={canChangeStatus}
/>
</TabPanel>
{/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onClose={() => !deleteLoading && setDeleteDialogOpen(false)}>
<DialogTitle>Gerät löschen</DialogTitle>
<DialogContent>
<DialogContentText>
Möchten Sie &apos;{equipment.bezeichnung}&apos; wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button
onClick={() => setDeleteDialogOpen(false)}
disabled={deleteLoading}
autoFocus
>
Abbrechen
</Button>
<Button
color="error"
variant="contained"
onClick={handleDelete}
disabled={deleteLoading}
startIcon={deleteLoading ? <CircularProgress size={16} /> : undefined}
>
Löschen
</Button>
</DialogActions>
</Dialog>
</Container>
</DashboardLayout>
);
}
export default AusruestungDetailPage;

View File

@@ -0,0 +1,521 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Alert,
Box,
Button,
CircularProgress,
Container,
FormControl,
FormControlLabel,
Grid,
InputLabel,
MenuItem,
Paper,
Select,
Switch,
TextField,
Typography,
} from '@mui/material';
import { ArrowBack, Save } from '@mui/icons-material';
import { useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { equipmentApi } from '../services/equipment';
import { vehiclesApi } from '../services/vehicles';
import {
AusruestungStatus,
AusruestungStatusLabel,
CreateAusruestungPayload,
UpdateAusruestungPayload,
AusruestungKategorie,
} from '../types/equipment.types';
import type { FahrzeugListItem } from '../types/vehicle.types';
import { usePermissions } from '../hooks/usePermissions';
// -- Form state shape ---------------------------------------------------------
interface FormState {
bezeichnung: string;
kategorie_id: string;
seriennummer: string;
inventarnummer: string;
hersteller: string;
baujahr: string; // stored as string for input, converted to number on submit
status: AusruestungStatus;
status_bemerkung: string;
ist_wichtig: boolean;
fahrzeug_id: string;
standort: string;
pruef_intervall_monate: string;
letzte_pruefung_am: string;
naechste_pruefung_am: string;
bemerkung: string;
}
const EMPTY_FORM: FormState = {
bezeichnung: '',
kategorie_id: '',
seriennummer: '',
inventarnummer: '',
hersteller: '',
baujahr: '',
status: AusruestungStatus.Einsatzbereit,
status_bemerkung: '',
ist_wichtig: false,
fahrzeug_id: '',
standort: 'Lager',
pruef_intervall_monate: '',
letzte_pruefung_am: '',
naechste_pruefung_am: '',
bemerkung: '',
};
// -- Helpers ------------------------------------------------------------------
/** Convert a Date ISO string like '2026-03-15T00:00:00.000Z' to 'YYYY-MM-DD' */
function toDateInput(iso: string | null | undefined): string {
if (!iso) return '';
return iso.slice(0, 10);
}
// -- Component ----------------------------------------------------------------
function AusruestungForm() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { canChangeStatus } = usePermissions();
const isEditMode = Boolean(id);
// -- Permission guard: only authorized users may create or edit equipment ----
if (!canChangeStatus) {
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üstung zu bearbeiten.
</Typography>
<Button variant="contained" onClick={() => navigate('/ausruestung')}>
Zurück zur Ausrüstungsübersicht
</Button>
</Box>
</Container>
</DashboardLayout>
);
}
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [loading, setLoading] = useState(isEditMode);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Partial<Record<keyof FormState, string>>>({});
// -- Lookup data ------------------------------------------------------------
const [categories, setCategories] = useState<AusruestungKategorie[]>([]);
const [vehicles, setVehicles] = useState<FahrzeugListItem[]>([]);
const fetchLookups = useCallback(async () => {
try {
const [cats, vehs] = await Promise.all([
equipmentApi.getCategories(),
vehiclesApi.getAll(),
]);
setCategories(cats);
setVehicles(vehs);
} catch {
// Non-critical: dropdowns will be empty but form still usable
}
}, []);
const fetchEquipment = useCallback(async () => {
if (!id) return;
try {
setLoading(true);
setError(null);
const equipment = await equipmentApi.getById(id);
setForm({
bezeichnung: equipment.bezeichnung,
kategorie_id: equipment.kategorie_id,
seriennummer: equipment.seriennummer ?? '',
inventarnummer: equipment.inventarnummer ?? '',
hersteller: equipment.hersteller ?? '',
baujahr: equipment.baujahr?.toString() ?? '',
status: equipment.status,
status_bemerkung: equipment.status_bemerkung ?? '',
ist_wichtig: equipment.ist_wichtig,
fahrzeug_id: equipment.fahrzeug_id ?? '',
standort: equipment.standort ?? 'Lager',
pruef_intervall_monate: equipment.pruef_intervall_monate?.toString() ?? '',
letzte_pruefung_am: toDateInput(equipment.letzte_pruefung_am),
naechste_pruefung_am: toDateInput(equipment.naechste_pruefung_am),
bemerkung: equipment.bemerkung ?? '',
});
} catch {
setError('Ausrüstung konnte nicht geladen werden.');
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
fetchLookups();
}, [fetchLookups]);
useEffect(() => {
if (isEditMode) fetchEquipment();
}, [isEditMode, fetchEquipment]);
// -- Validation -------------------------------------------------------------
const validate = (): boolean => {
const errors: Partial<Record<keyof FormState, string>> = {};
if (!form.bezeichnung.trim()) {
errors.bezeichnung = 'Bezeichnung ist erforderlich.';
}
if (!form.kategorie_id) {
errors.kategorie_id = 'Kategorie ist erforderlich.';
}
if (form.baujahr) {
const year = parseInt(form.baujahr, 10);
if (isNaN(year) || year < 1950 || year > 2100) {
errors.baujahr = 'Baujahr muss zwischen 1950 und 2100 liegen.';
}
}
if (form.pruef_intervall_monate) {
const months = parseInt(form.pruef_intervall_monate, 10);
if (isNaN(months) || months < 1 || months > 120) {
errors.pruef_intervall_monate = 'Prüfintervall muss zwischen 1 und 120 Monaten liegen.';
}
}
setFieldErrors(errors);
return Object.keys(errors).length === 0;
};
// -- Submit -----------------------------------------------------------------
const handleSubmit = async () => {
if (!validate()) return;
try {
setSaving(true);
setSaveError(null);
if (isEditMode && id) {
const payload: UpdateAusruestungPayload = {
bezeichnung: form.bezeichnung.trim() || undefined,
kategorie_id: form.kategorie_id || undefined,
seriennummer: form.seriennummer.trim() || undefined,
inventarnummer: form.inventarnummer.trim() || undefined,
hersteller: form.hersteller.trim() || undefined,
baujahr: form.baujahr ? parseInt(form.baujahr, 10) : undefined,
status: form.status,
status_bemerkung: form.status_bemerkung.trim() || undefined,
ist_wichtig: form.ist_wichtig,
fahrzeug_id: form.fahrzeug_id || null,
standort: !form.fahrzeug_id ? (form.standort.trim() || 'Lager') : undefined,
pruef_intervall_monate: form.pruef_intervall_monate ? parseInt(form.pruef_intervall_monate, 10) : undefined,
letzte_pruefung_am: form.letzte_pruefung_am || undefined,
naechste_pruefung_am: form.naechste_pruefung_am || undefined,
bemerkung: form.bemerkung.trim() || undefined,
};
await equipmentApi.update(id, payload);
navigate(`/ausruestung/${id}`);
} else {
const payload: CreateAusruestungPayload = {
bezeichnung: form.bezeichnung.trim(),
kategorie_id: form.kategorie_id,
seriennummer: form.seriennummer.trim() || undefined,
inventarnummer: form.inventarnummer.trim() || undefined,
hersteller: form.hersteller.trim() || undefined,
baujahr: form.baujahr ? parseInt(form.baujahr, 10) : undefined,
status: form.status,
status_bemerkung: form.status_bemerkung.trim() || undefined,
ist_wichtig: form.ist_wichtig,
fahrzeug_id: form.fahrzeug_id || undefined,
standort: !form.fahrzeug_id ? (form.standort.trim() || 'Lager') : undefined,
pruef_intervall_monate: form.pruef_intervall_monate ? parseInt(form.pruef_intervall_monate, 10) : undefined,
letzte_pruefung_am: form.letzte_pruefung_am || undefined,
naechste_pruefung_am: form.naechste_pruefung_am || undefined,
bemerkung: form.bemerkung.trim() || undefined,
};
const created = await equipmentApi.create(payload);
navigate(`/ausruestung/${created.id}`);
}
} catch {
setSaveError(
isEditMode
? 'Ausrüstung konnte nicht gespeichert werden.'
: 'Ausrüstung konnte nicht erstellt werden.'
);
} finally {
setSaving(false);
}
};
// -- Field helper -----------------------------------------------------------
const f = (field: keyof FormState) => ({
value: form[field] as string,
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((prev) => ({ ...prev, [field]: e.target.value })),
error: Boolean(fieldErrors[field]),
helperText: fieldErrors[field],
});
// -- Loading / Error early returns ------------------------------------------
if (loading) {
return (
<DashboardLayout>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
</DashboardLayout>
);
}
if (error) {
return (
<DashboardLayout>
<Container maxWidth="md">
<Alert severity="error">{error}</Alert>
<Button startIcon={<ArrowBack />} onClick={() => navigate('/ausruestung')} sx={{ mt: 2 }}>
Zurück
</Button>
</Container>
</DashboardLayout>
);
}
// -- Render -----------------------------------------------------------------
return (
<DashboardLayout>
<Container maxWidth="md">
<Button
startIcon={<ArrowBack />}
onClick={() => (isEditMode && id ? navigate(`/ausruestung/${id}`) : navigate('/ausruestung'))}
sx={{ mb: 2 }}
size="small"
>
{isEditMode ? 'Zurück zur Detailansicht' : 'Ausrüstungsübersicht'}
</Button>
<Typography variant="h4" gutterBottom>
{isEditMode ? 'Gerät bearbeiten' : 'Neues Gerät anlegen'}
</Typography>
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
<Paper variant="outlined" sx={{ p: 3 }}>
{/* ── Section: Grunddaten ──────────────────────────────────────────── */}
<Typography variant="h6" gutterBottom>Grunddaten</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={8}>
<TextField
label="Bezeichnung *"
fullWidth
{...f('bezeichnung')}
inputProps={{ maxLength: 200 }}
placeholder="z.B. Atemschutzgerät Dräger PSS 5000"
/>
</Grid>
<Grid item xs={12} sm={4}>
<FormControl fullWidth error={Boolean(fieldErrors.kategorie_id)}>
<InputLabel>Kategorie *</InputLabel>
<Select
label="Kategorie *"
value={form.kategorie_id}
onChange={(e) => setForm((prev) => ({ ...prev, kategorie_id: e.target.value as string }))}
>
{categories.map((cat) => (
<MenuItem key={cat.id} value={cat.id}>
{cat.name}
</MenuItem>
))}
</Select>
{fieldErrors.kategorie_id && (
<Typography variant="caption" color="error" sx={{ mt: 0.5, ml: 1.5 }}>
{fieldErrors.kategorie_id}
</Typography>
)}
</FormControl>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Seriennummer"
fullWidth
{...f('seriennummer')}
inputProps={{ maxLength: 100 }}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Inventarnummer"
fullWidth
{...f('inventarnummer')}
inputProps={{ maxLength: 50 }}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Hersteller"
fullWidth
{...f('hersteller')}
inputProps={{ maxLength: 150 }}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Baujahr"
type="number"
fullWidth
{...f('baujahr')}
inputProps={{ min: 1950, max: 2100 }}
/>
</Grid>
</Grid>
{/* ── Section: Status & Zuordnung ──────────────────────────────────── */}
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Status &amp; Zuordnung</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={4}>
<FormControl fullWidth>
<InputLabel>Status</InputLabel>
<Select
label="Status"
value={form.status}
onChange={(e) => setForm((prev) => ({ ...prev, status: e.target.value as AusruestungStatus }))}
>
{Object.values(AusruestungStatus).map((s) => (
<MenuItem key={s} value={s}>{AusruestungStatusLabel[s]}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{form.status !== AusruestungStatus.Einsatzbereit && (
<Grid item xs={12} sm={8}>
<TextField
label="Status-Bemerkung"
fullWidth
multiline
{...f('status_bemerkung')}
placeholder="z.B. Defektes Ventil, Reparatur beauftragt"
/>
</Grid>
)}
<Grid item xs={12}>
<FormControlLabel
control={
<Switch
checked={form.ist_wichtig}
onChange={(e) => setForm((prev) => ({ ...prev, ist_wichtig: e.target.checked }))}
/>
}
label="Wichtiges Gerät (Warnung auf Fahrzeugkarte wenn nicht einsatzbereit)"
/>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Fahrzeug</InputLabel>
<Select
label="Fahrzeug"
value={form.fahrzeug_id}
onChange={(e) => setForm((prev) => ({ ...prev, fahrzeug_id: e.target.value as string }))}
>
<MenuItem value="">Kein Fahrzeug (Lager)</MenuItem>
{vehicles.map((v) => (
<MenuItem key={v.id} value={v.id}>
{v.bezeichnung}{v.kurzname ? ` (${v.kurzname})` : ''}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{!form.fahrzeug_id && (
<Grid item xs={12} sm={6}>
<TextField
label="Standort"
fullWidth
{...f('standort')}
placeholder="z.B. Lager, Regal A3"
/>
</Grid>
)}
</Grid>
{/* ── Section: Pruefung & Wartung ───────────────────────────────────── */}
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Prüfung &amp; Wartung</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={4}>
<TextField
label="Prüfintervall (Monate)"
type="number"
fullWidth
{...f('pruef_intervall_monate')}
inputProps={{ min: 1, max: 120 }}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Letzte Prüfung"
type="date"
fullWidth
value={form.letzte_pruefung_am}
onChange={(e) => setForm((prev) => ({ ...prev, letzte_pruefung_am: e.target.value }))}
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Nächste Prüfung"
type="date"
fullWidth
value={form.naechste_pruefung_am}
onChange={(e) => setForm((prev) => ({ ...prev, naechste_pruefung_am: e.target.value }))}
InputLabelProps={{ shrink: true }}
/>
</Grid>
</Grid>
{/* ── Section: Bemerkungen ──────────────────────────────────────────── */}
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Bemerkungen</Typography>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
label="Bemerkung"
fullWidth
multiline
rows={3}
{...f('bemerkung')}
placeholder="Zusätzliche Informationen zum Gerät"
/>
</Grid>
</Grid>
</Paper>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 3 }}>
<Button
variant="outlined"
onClick={() => (isEditMode && id ? navigate(`/ausruestung/${id}`) : navigate('/ausruestung'))}
>
Abbrechen
</Button>
<Button
variant="contained"
startIcon={saving ? <CircularProgress size={16} /> : <Save />}
onClick={handleSubmit}
disabled={saving}
>
{isEditMode ? 'Änderungen speichern' : 'Gerät erstellen'}
</Button>
</Box>
</Container>
</DashboardLayout>
);
}
export default AusruestungForm;

View File

@@ -9,6 +9,7 @@ import {
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
Fab,
@@ -16,11 +17,18 @@ import {
Grid,
IconButton,
InputLabel,
Link,
MenuItem,
Paper,
Select,
Stack,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tabs,
TextField,
Tooltip,
@@ -32,6 +40,7 @@ import {
Assignment,
Build,
CheckCircle,
DeleteOutline,
DirectionsCar,
Edit,
Error as ErrorIcon,
@@ -40,12 +49,14 @@ import {
PauseCircle,
ReportProblem,
School,
Star,
Verified,
Warning,
} from '@mui/icons-material';
import { useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { vehiclesApi } from '../services/vehicles';
import { equipmentApi } from '../services/equipment';
import {
FahrzeugDetail,
FahrzeugWartungslog,
@@ -55,7 +66,10 @@ import {
UpdateStatusPayload,
WartungslogArt,
} from '../types/vehicle.types';
import type { AusruestungListItem } from '../types/equipment.types';
import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types';
import { usePermissions } from '../hooks/usePermissions';
import { useNotification } from '../contexts/NotificationContext';
// ── Tab Panel ─────────────────────────────────────────────────────────────────
@@ -195,12 +209,6 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
{ label: 'Bezeichnung', value: vehicle.bezeichnung },
{ label: 'Kurzname', value: vehicle.kurzname },
{ label: 'Kennzeichen', value: vehicle.amtliches_kennzeichen },
{ label: 'Fahrgestellnr.', value: vehicle.fahrgestellnummer },
{ label: 'Baujahr', value: vehicle.baujahr?.toString() },
{ label: 'Hersteller', value: vehicle.hersteller },
{ label: 'Typ (DIN 14502)', value: vehicle.typ_schluessel },
{ label: 'Besatzung (Soll)', value: vehicle.besatzung_soll },
{ label: 'Standort', value: vehicle.standort },
].map(({ label, value }) => (
<Grid item xs={12} sm={6} md={4} key={label}>
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
@@ -492,17 +500,153 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
);
};
// ── Ausrüstung Tab ───────────────────────────────────────────────────────────
const EQUIPMENT_STATUS_COLOR: Record<AusruestungStatus, 'success' | 'error' | 'warning' | 'default'> = {
[AusruestungStatus.Einsatzbereit]: 'success',
[AusruestungStatus.Beschaedigt]: 'error',
[AusruestungStatus.InWartung]: 'warning',
[AusruestungStatus.AusserDienst]: 'default',
};
function pruefungBadgeColor(tage: number | null): 'success' | 'warning' | 'error' | 'default' {
if (tage === null) return 'default';
if (tage < 0) return 'error';
if (tage <= 30) return 'warning';
return 'success';
}
interface AusruestungTabProps {
equipment: AusruestungListItem[];
vehicleId: string;
}
const AusruestungTab: React.FC<AusruestungTabProps> = ({ equipment, vehicleId }) => {
const navigate = useNavigate();
const hasProblems = equipment.some(
(e) => e.status === AusruestungStatus.Beschaedigt || e.status === AusruestungStatus.InWartung
);
if (equipment.length === 0) {
return (
<Box sx={{ textAlign: 'center', py: 8 }}>
<Assignment sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
<Typography variant="h6" color="text.secondary">
Keine Ausrüstung zugewiesen
</Typography>
<Typography variant="body2" color="text.disabled" sx={{ mb: 2 }}>
Diesem Fahrzeug ist derzeit keine Ausrüstung zugeordnet.
</Typography>
<Button
variant="outlined"
size="small"
onClick={() => navigate('/ausruestung')}
>
Zur Ausrüstungsverwaltung
</Button>
</Box>
);
}
return (
<Box>
{hasProblems && (
<Alert severity="warning" icon={<Warning />} sx={{ mb: 2 }}>
<strong>Achtung:</strong> Eine oder mehrere Ausrüstungen dieses Fahrzeugs sind beschädigt oder in Wartung.
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Kategorie</TableCell>
<TableCell>Status</TableCell>
<TableCell align="center">Wichtig</TableCell>
<TableCell>Nächste Prüfung</TableCell>
</TableRow>
</TableHead>
<TableBody>
{equipment.map((item) => {
const statusColor = EQUIPMENT_STATUS_COLOR[item.status] ?? 'default';
const pruefTage = item.pruefung_tage_bis_faelligkeit;
const pruefColor = pruefungBadgeColor(pruefTage);
return (
<TableRow key={item.id} hover>
<TableCell>
<Link
component="button"
variant="body2"
fontWeight={600}
underline="hover"
onClick={() => navigate(`/ausruestung/${item.id}`)}
sx={{ textAlign: 'left' }}
>
{item.bezeichnung}
</Link>
</TableCell>
<TableCell>
<Chip label={item.kategorie_kurzname} size="small" variant="outlined" />
</TableCell>
<TableCell>
<Chip
label={AusruestungStatusLabel[item.status]}
size="small"
color={statusColor}
/>
</TableCell>
<TableCell align="center">
{item.ist_wichtig && (
<Tooltip title="Wichtige Ausrüstung">
<Star fontSize="small" color="warning" />
</Tooltip>
)}
</TableCell>
<TableCell>
{item.naechste_pruefung_am ? (
<Chip
size="small"
color={pruefColor}
variant={pruefColor === 'default' ? 'outlined' : 'filled'}
label={
pruefTage !== null && pruefTage < 0
? `ÜBERFÄLLIG (${fmtDate(item.naechste_pruefung_am)})`
: fmtDate(item.naechste_pruefung_am)
}
icon={pruefTage !== null && pruefTage < 0 ? <Warning fontSize="small" /> : undefined}
/>
) : (
<Typography variant="body2" color="text.disabled"></Typography>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Box>
);
};
// ── Main Page ─────────────────────────────────────────────────────────────────
function FahrzeugDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAdmin, canChangeStatus } = usePermissions();
const notification = useNotification();
const [vehicle, setVehicle] = useState<FahrzeugDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState(0);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [vehicleEquipment, setVehicleEquipment] = useState<AusruestungListItem[]>([]);
const fetchVehicle = useCallback(async () => {
if (!id) return;
@@ -511,6 +655,13 @@ function FahrzeugDetail() {
setError(null);
const data = await vehiclesApi.getById(id);
setVehicle(data);
// Fetch equipment separately — failure must not break the page
try {
const eq = await equipmentApi.getByVehicle(id);
setVehicleEquipment(eq);
} catch {
setVehicleEquipment([]);
}
} catch {
setError('Fahrzeug konnte nicht geladen werden.');
} finally {
@@ -520,6 +671,20 @@ function FahrzeugDetail() {
useEffect(() => { fetchVehicle(); }, [fetchVehicle]);
const handleDeleteVehicle = async () => {
if (!id) return;
try {
setDeleteLoading(true);
await vehiclesApi.delete(id);
notification.showSuccess('Fahrzeug wurde erfolgreich gelöscht.');
navigate('/fahrzeuge');
} catch {
notification.showError('Fahrzeug konnte nicht gelöscht werden.');
setDeleteDialogOpen(false);
setDeleteLoading(false);
}
};
if (loading) {
return (
<DashboardLayout>
@@ -573,7 +738,6 @@ function FahrzeugDetail() {
{vehicle.amtliches_kennzeichen && (
<Typography variant="subtitle1" color="text.secondary">
{vehicle.amtliches_kennzeichen}
{vehicle.hersteller && ` · ${vehicle.hersteller}`}
</Typography>
)}
</Box>
@@ -594,6 +758,18 @@ function FahrzeugDetail() {
</IconButton>
</Tooltip>
)}
{isAdmin && (
<Tooltip title="Fahrzeug löschen">
<IconButton
size="small"
color="error"
onClick={() => setDeleteDialogOpen(true)}
aria-label="Fahrzeug löschen"
>
<DeleteOutline />
</IconButton>
</Tooltip>
)}
</Box>
</Box>
@@ -614,6 +790,7 @@ function FahrzeugDetail() {
}
/>
<Tab label="Einsätze" />
<Tab label={`Ausrüstung${vehicleEquipment.length > 0 ? ` (${vehicleEquipment.length})` : ''}`} />
</Tabs>
</Box>
@@ -645,6 +822,38 @@ function FahrzeugDetail() {
</Typography>
</Box>
</TabPanel>
<TabPanel value={activeTab} index={3}>
<AusruestungTab equipment={vehicleEquipment} vehicleId={vehicle.id} />
</TabPanel>
{/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onClose={() => !deleteLoading && setDeleteDialogOpen(false)}>
<DialogTitle>Fahrzeug löschen</DialogTitle>
<DialogContent>
<DialogContentText>
Möchten Sie das Fahrzeug &apos;{vehicle.bezeichnung}&apos; wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button
onClick={() => setDeleteDialogOpen(false)}
disabled={deleteLoading}
autoFocus
>
Abbrechen
</Button>
<Button
color="error"
variant="contained"
onClick={handleDeleteVehicle}
disabled={deleteLoading}
startIcon={deleteLoading ? <CircularProgress size={16} /> : undefined}
>
Löschen
</Button>
</DialogActions>
</Dialog>
</Container>
</DashboardLayout>
);

View File

@@ -24,6 +24,7 @@ import {
CreateFahrzeugPayload,
UpdateFahrzeugPayload,
} from '../types/vehicle.types';
import { usePermissions } from '../hooks/usePermissions';
// ── Form state shape ──────────────────────────────────────────────────────────
@@ -74,8 +75,30 @@ function toDateInput(iso: string | null | undefined): string {
function FahrzeugForm() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAdmin } = usePermissions();
const isEditMode = Boolean(id);
// ── Permission guard: only admins may create or edit vehicles ──────────────
if (!isAdmin) {
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 Fahrzeuge zu bearbeiten.
</Typography>
<Button variant="contained" onClick={() => navigate('/fahrzeuge')}>
Zurück zur Fahrzeugübersicht
</Button>
</Box>
</Container>
</DashboardLayout>
);
}
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [loading, setLoading] = useState(isEditMode);
const [saving, setSaving] = useState(false);
@@ -121,9 +144,6 @@ function FahrzeugForm() {
if (!form.bezeichnung.trim()) {
errors.bezeichnung = 'Bezeichnung ist erforderlich.';
}
if (form.baujahr && (isNaN(Number(form.baujahr)) || Number(form.baujahr) < 1950 || Number(form.baujahr) > 2100)) {
errors.baujahr = 'Baujahr muss zwischen 1950 und 2100 liegen.';
}
setFieldErrors(errors);
return Object.keys(errors).length === 0;
};
@@ -261,53 +281,6 @@ function FahrzeugForm() {
placeholder="z.B. WN-FW 1"
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Fahrgestellnummer (VIN)"
fullWidth
{...f('fahrgestellnummer')}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Baujahr"
type="number"
fullWidth
{...f('baujahr')}
inputProps={{ min: 1950, max: 2100 }}
/>
</Grid>
<Grid item xs={12} sm={8}>
<TextField
label="Hersteller"
fullWidth
{...f('hersteller')}
placeholder="z.B. MAN TGM / Rosenbauer"
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Typ-Schlüssel (DIN 14502)"
fullWidth
{...f('typ_schluessel')}
placeholder="z.B. LF 10"
/>
</Grid>
<Grid item xs={12} sm={3}>
<TextField
label="Besatzung (Soll)"
fullWidth
{...f('besatzung_soll')}
placeholder="z.B. 1/8"
/>
</Grid>
<Grid item xs={12} sm={3}>
<TextField
label="Standort"
fullWidth
{...f('standort')}
/>
</Grid>
</Grid>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Status</Typography>

View File

@@ -31,6 +31,9 @@ import {
import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { vehiclesApi } from '../services/vehicles';
import { equipmentApi } from '../services/equipment';
import type { VehicleEquipmentWarning } from '../types/equipment.types';
import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types';
import {
FahrzeugListItem,
FahrzeugStatus,
@@ -86,9 +89,10 @@ function inspTooltipTitle(fullLabel: string, tage: number | null, faelligAm: str
interface VehicleCardProps {
vehicle: FahrzeugListItem;
onClick: (id: string) => void;
warnings?: VehicleEquipmentWarning[];
}
const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick, warnings = [] }) => {
const status = vehicle.status as FahrzeugStatus;
const statusCfg = STATUS_CONFIG[status] ?? STATUS_CONFIG[FahrzeugStatus.Einsatzbereit];
const isSchaden = status === FahrzeugStatus.AusserDienstSchaden;
@@ -183,13 +187,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
/>
</Box>
{vehicle.besatzung_soll && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Besatzung: {vehicle.besatzung_soll}
{vehicle.baujahr && ` · Bj. ${vehicle.baujahr}`}
</Typography>
)}
{inspBadges.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{inspBadges.map((b) => {
@@ -214,6 +211,18 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
})}
</Box>
)}
{warnings.length > 0 && (
<Tooltip title={warnings.map(w => `${w.bezeichnung}: ${AusruestungStatusLabel[w.status]}`).join('\n')}>
<Chip
size="small"
icon={<Warning />}
label={`${warnings.length} Ausrüstung nicht bereit`}
color={warnings.some(w => w.status === AusruestungStatus.Beschaedigt) ? 'error' : 'warning'}
sx={{ mt: 0.5 }}
/>
</Tooltip>
)}
</CardContent>
</CardActionArea>
</Card>
@@ -229,6 +238,7 @@ function Fahrzeuge() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [equipmentWarnings, setEquipmentWarnings] = useState<Map<string, VehicleEquipmentWarning[]>>(new Map());
const fetchVehicles = useCallback(async () => {
try {
@@ -245,6 +255,26 @@ function Fahrzeuge() {
useEffect(() => { fetchVehicles(); }, [fetchVehicles]);
// Fetch equipment warnings separately — must not block or delay vehicle list rendering
useEffect(() => {
async function fetchWarnings() {
try {
const warnings = await equipmentApi.getVehicleWarnings();
const warningsMap = new Map<string, VehicleEquipmentWarning[]>();
warnings.forEach(w => {
const existing = warningsMap.get(w.fahrzeug_id) || [];
existing.push(w);
warningsMap.set(w.fahrzeug_id, existing);
});
setEquipmentWarnings(warningsMap);
} catch {
// Silently fail — equipment warnings are non-critical
setEquipmentWarnings(new Map());
}
}
fetchWarnings();
}, []);
const filtered = vehicles.filter((v) => {
if (!search.trim()) return true;
const q = search.toLowerCase();
@@ -337,6 +367,7 @@ function Fahrzeuge() {
<VehicleCard
vehicle={vehicle}
onClick={(id) => navigate(`/fahrzeuge/${id}`)}
warnings={equipmentWarnings.get(vehicle.id) || []}
/>
</Grid>
))}

View File

@@ -0,0 +1,111 @@
import { api } from './api';
import type {
AusruestungListItem,
AusruestungDetail,
AusruestungWartungslog,
AusruestungKategorie,
EquipmentStats,
VehicleEquipmentWarning,
CreateAusruestungPayload,
UpdateAusruestungPayload,
UpdateAusruestungStatusPayload,
CreateAusruestungWartungslogPayload,
} from '../types/equipment.types';
async function unwrap<T>(
promise: ReturnType<typeof api.get<{ success: boolean; data: T }>>
): Promise<T> {
const response = await promise;
if (response.data?.data === undefined || response.data?.data === null) {
throw new Error('Invalid API response');
}
return response.data.data;
}
export const equipmentApi = {
async getAll(): Promise<AusruestungListItem[]> {
return unwrap(api.get<{ success: boolean; data: AusruestungListItem[] }>('/api/equipment'));
},
async getById(id: string): Promise<AusruestungDetail> {
return unwrap(api.get<{ success: boolean; data: AusruestungDetail }>(`/api/equipment/${id}`));
},
async getByVehicle(fahrzeugId: string): Promise<AusruestungListItem[]> {
return unwrap(
api.get<{ success: boolean; data: AusruestungListItem[] }>(
`/api/equipment/vehicle/${fahrzeugId}`
)
);
},
async getCategories(): Promise<AusruestungKategorie[]> {
return unwrap(
api.get<{ success: boolean; data: AusruestungKategorie[] }>('/api/equipment/categories')
);
},
async getStats(): Promise<EquipmentStats> {
return unwrap(api.get<{ success: boolean; data: EquipmentStats }>('/api/equipment/stats'));
},
async getAlerts(daysAhead = 30): Promise<AusruestungListItem[]> {
return unwrap(
api.get<{ success: boolean; data: AusruestungListItem[] }>(
`/api/equipment/alerts?daysAhead=${daysAhead}`
)
);
},
async getVehicleWarnings(): Promise<VehicleEquipmentWarning[]> {
return unwrap(
api.get<{ success: boolean; data: VehicleEquipmentWarning[] }>(
'/api/equipment/vehicle-warnings'
)
);
},
async create(payload: CreateAusruestungPayload): Promise<AusruestungDetail> {
const response = await api.post<{ success: boolean; data: AusruestungDetail }>(
'/api/equipment',
payload
);
if (response.data?.data === undefined || response.data?.data === null) {
throw new Error('Invalid API response');
}
return response.data.data;
},
async update(id: string, payload: UpdateAusruestungPayload): Promise<AusruestungDetail> {
const response = await api.patch<{ success: boolean; data: AusruestungDetail }>(
`/api/equipment/${id}`,
payload
);
if (response.data?.data === undefined || response.data?.data === null) {
throw new Error('Invalid API response');
}
return response.data.data;
},
async delete(id: string): Promise<void> {
await api.delete(`/api/equipment/${id}`);
},
async updateStatus(id: string, payload: UpdateAusruestungStatusPayload): Promise<void> {
await api.patch(`/api/equipment/${id}/status`, payload);
},
async addWartungslog(
id: string,
payload: CreateAusruestungWartungslogPayload
): Promise<AusruestungWartungslog> {
const response = await api.post<{ success: boolean; data: AusruestungWartungslog }>(
`/api/equipment/${id}/wartung`,
payload
);
if (response.data?.data === undefined || response.data?.data === null) {
throw new Error('Invalid API response');
}
return response.data.data;
},
};

View File

@@ -0,0 +1,136 @@
// =============================================================================
// Equipment Management — Frontend Type Definitions
// =============================================================================
export enum AusruestungStatus {
Einsatzbereit = 'einsatzbereit',
Beschaedigt = 'beschaedigt',
InWartung = 'in_wartung',
AusserDienst = 'ausser_dienst',
}
export const AusruestungStatusLabel: Record<AusruestungStatus, string> = {
[AusruestungStatus.Einsatzbereit]: 'Einsatzbereit',
[AusruestungStatus.Beschaedigt]: 'Beschädigt',
[AusruestungStatus.InWartung]: 'In Wartung',
[AusruestungStatus.AusserDienst]: 'Außer Dienst',
};
export type AusruestungWartungslogArt = 'Prüfung' | 'Reparatur' | 'Sonstiges';
// ── Lookup Entity ────────────────────────────────────────────────────────────
export interface AusruestungKategorie {
id: string;
name: string;
kurzname: string;
sortierung: number;
}
// ── API Response Shapes ──────────────────────────────────────────────────────
export interface AusruestungListItem {
id: string;
bezeichnung: string;
kategorie_id: string;
kategorie_name: string;
kategorie_kurzname: string;
seriennummer: string | null;
inventarnummer: string | null;
hersteller: string | null;
baujahr: number | null;
status: AusruestungStatus;
status_bemerkung: string | null;
ist_wichtig: boolean;
fahrzeug_id: string | null;
fahrzeug_bezeichnung: string | null;
fahrzeug_kurzname: string | null;
standort: string;
naechste_pruefung_am: string | null;
pruefung_tage_bis_faelligkeit: number | null;
created_at: string;
updated_at: string;
}
export interface AusruestungDetail extends AusruestungListItem {
pruef_intervall_monate: number | null;
letzte_pruefung_am: string | null;
bemerkung: string | null;
wartungslog: AusruestungWartungslog[];
}
export interface AusruestungWartungslog {
id: string;
ausruestung_id: string;
datum: string;
art: AusruestungWartungslogArt;
beschreibung: string;
ergebnis: string | null;
kosten: number | null;
pruefende_stelle: string | null;
dokument_url: string | null;
erfasst_von: string | null;
created_at: string;
}
// ── Dashboard KPI ────────────────────────────────────────────────────────────
export interface EquipmentStats {
total: number;
einsatzbereit: number;
beschaedigt: number;
inWartung: number;
ausserDienst: number;
inspectionsDue: number;
inspectionsOverdue: number;
wichtigNichtBereit: number;
}
// ── Vehicle Equipment Warning ────────────────────────────────────────────────
export interface VehicleEquipmentWarning {
fahrzeug_id: string;
ausruestung_id: string;
bezeichnung: string;
status: AusruestungStatus;
kategorie_name: string;
}
// ── Request Payload Types ────────────────────────────────────────────────────
export interface CreateAusruestungPayload {
bezeichnung: string;
kategorie_id: string;
seriennummer?: string;
inventarnummer?: string;
hersteller?: string;
baujahr?: number;
status?: AusruestungStatus;
status_bemerkung?: string;
ist_wichtig?: boolean;
fahrzeug_id?: string;
standort?: string;
pruef_intervall_monate?: number;
letzte_pruefung_am?: string;
naechste_pruefung_am?: string;
bemerkung?: string;
}
export type UpdateAusruestungPayload = {
[K in keyof CreateAusruestungPayload]?: CreateAusruestungPayload[K] | null;
};
export interface UpdateAusruestungStatusPayload {
status: AusruestungStatus;
bemerkung?: string;
}
export interface CreateAusruestungWartungslogPayload {
datum: string;
art: AusruestungWartungslogArt;
beschreibung: string;
ergebnis?: string;
kosten?: number;
pruefende_stelle?: string;
dokument_url?: string;
}

View File

@@ -0,0 +1,688 @@
# Feuerwehr Dashboard — Vehicle & Equipment Feature Plan
**Date:** 2026-02-28
**Author:** Claude (brainstorm + analysis)
---
## Feature Status Summary
| # | Feature | Status | Work Needed |
|---|---------|--------|-------------|
| 1 | Add vehicle | DONE | None |
| 2 | Edit vehicle | DONE | None |
| 3 | Remove vehicle | BACKEND ONLY | Frontend UI (delete button + confirmation dialog) |
| 4 | Permission visibility | PARTIAL | Hide restricted UI elements + frontend route guards |
| 5 | Remove info fields | NOT DONE | Remove 6 fields from form, detail, list view |
| 6 | Equipment system | NOT IMPLEMENTED | Full-stack (DB, backend, frontend) |
| 7 | Equipment on vehicle detail | NOT IMPLEMENTED | New tab on vehicle detail page |
| 8 | Equipment warnings on vehicle cards | NOT IMPLEMENTED | Warning badges on vehicle overview |
---
## Priority Order
### Phase 1 — Quick Wins (vehicle cleanup)
1. **P1: Remove info fields** — Remove Fahrgestellnr., Standort, Besatzung, Typ, Hersteller, Baujahr from frontend
2. **P2: Delete vehicle UI** — Add delete button + confirmation dialog to FahrzeugDetail.tsx
3. **P3: Permission guards** — Hide restricted features from unauthorized user groups
### Phase 2 — Equipment Backend
4. **P4: Database migration** — Create equipment tables (ausruestung, ausruestung_kategorien, ausruestung_wartungslog)
5. **P5: Backend model + service + controller + routes** — Full backend CRUD for equipment
### Phase 3 — Equipment Frontend
6. **P6: Equipment list page** — Replace placeholder with full equipment management page
7. **P7: Equipment create/edit form** — AusruestungForm.tsx
8. **P8: Equipment detail page** — AusruestungDetail.tsx with tabs
### Phase 4 — Vehicle-Equipment Integration
9. **P9: Vehicle detail equipment tab** — Show assigned equipment in vehicle detail
10. **P10: Vehicle card warning badges** — Show warnings when important equipment is not ready
---
## Subagent Prompts
### PROMPT 1: Remove Info Fields (P1)
```
You are a senior React/TypeScript developer working on a Feuerwehr (fire department) Dashboard.
TASK: Remove these 6 info fields from the frontend display:
- Fahrgestellnr. (fahrgestellnummer)
- Standort (standort)
- Besatzung (besatzung_soll)
- Typ (typ_schluessel)
- Hersteller (hersteller)
- Baujahr (baujahr)
FILES TO MODIFY:
1. /Users/matthias/work/feuerwehr_dashboard/frontend/src/pages/FahrzeugForm.tsx
- Remove the TextField inputs for all 6 fields from the form
- Remove them from the form state (formData) and any related onChange handlers
- Keep the fields in the TypeScript types and backend — only remove from UI
2. /Users/matthias/work/feuerwehr_dashboard/frontend/src/pages/FahrzeugDetail.tsx
- Remove the display rows for these 6 fields from the "Übersicht" tab data grid
- They appear in the info section showing vehicle details
3. /Users/matthias/work/feuerwehr_dashboard/frontend/src/pages/Fahrzeuge.tsx
- Remove besatzung_soll and baujahr from the VehicleCard component display
- Remove hersteller from the search filter if it's included
- Keep the TypeScript types intact — only remove visual display
IMPORTANT:
- Do NOT modify backend code, database, or TypeScript type definitions
- Do NOT remove the fields from API payloads — just stop displaying them
- Clean up any unused imports after removing the fields
- Verify the layout still looks good after removal (no empty gaps)
- Read each file first before making changes
```
### PROMPT 2: Delete Vehicle UI (P2)
```
You are a senior React/TypeScript developer working on a Feuerwehr Dashboard.
TASK: Add a delete button with confirmation dialog to the vehicle detail page.
CONTEXT:
- Backend soft-delete endpoint already exists: DELETE /api/vehicles/:id (admin only)
- Frontend API method exists: vehiclesApi.delete(id) in /frontend/src/services/vehicles.ts
- Permission hook: usePermissions() returns { isAdmin, canChangeStatus }
- Delete should only be visible to admin users (isAdmin === true)
FILE TO MODIFY: /Users/matthias/work/feuerwehr_dashboard/frontend/src/pages/FahrzeugDetail.tsx
REQUIREMENTS:
1. Add a DELETE button (red/error color) next to the existing EDIT button in the header area
- Only visible when isAdmin is true (same guard as edit button)
- Use MUI DeleteOutline or Delete icon
- Button variant: outlined, color: error
2. Add a CONFIRMATION DIALOG (MUI Dialog component):
- Title: "Fahrzeug löschen"
- Body: "Möchten Sie das Fahrzeug '{vehicle.bezeichnung}' wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden."
- Cancel button: "Abbrechen" (default, autofocus)
- Confirm button: "Löschen" (red/error color)
3. On confirm:
- Call vehiclesApi.delete(id)
- Show success notification (use the existing notification system/context)
- Navigate to /fahrzeuge after successful deletion
- Handle errors: show error notification if delete fails
4. State management:
- useState for dialog open/close
- useState for loading state during delete (disable buttons)
PATTERNS TO FOLLOW:
- Look at how the status change dialog works in the same file for patterns
- Use the existing useNotification() or similar notification hook
- Follow existing import patterns and code style
- Read the file first to understand the existing structure
```
### PROMPT 3: Permission Guards (P3)
```
You are a senior React/TypeScript developer working on a Feuerwehr Dashboard.
TASK: Ensure features that require specific user groups are NOT VISIBLE to unauthorized users.
CONTEXT:
- usePermissions() hook at /frontend/src/hooks/usePermissions.ts returns { isAdmin, canChangeStatus, groups }
- Groups: dashboard_admin (full access), dashboard_fahrmeister (status + wartung)
- Backend already enforces permissions via requireGroups() middleware
- The goal is to also HIDE the UI elements so users don't see options they can't use
FILES TO CHECK AND MODIFY:
1. /Users/matthias/work/feuerwehr_dashboard/frontend/src/pages/Fahrzeuge.tsx
- Verify: FAB "+" button only shows for isAdmin ✓ (already done)
2. /Users/matthias/work/feuerwehr_dashboard/frontend/src/pages/FahrzeugDetail.tsx
- Verify: Edit button only shows for isAdmin ✓ (already done)
- Verify: Status change controls only show for canChangeStatus
- Verify: Wartungslog add form only shows for canChangeStatus
- ADD: Delete button guard (if prompt P2 has been applied)
3. /Users/matthias/work/feuerwehr_dashboard/frontend/src/pages/FahrzeugForm.tsx
- ADD: If a non-admin navigates directly to /fahrzeuge/neu or /fahrzeuge/:id/bearbeiten,
show an "access denied" message or redirect to /fahrzeuge
- Use usePermissions() to check isAdmin
- Show a simple Card with "Keine Berechtigung" message and a back button
4. /Users/matthias/work/feuerwehr_dashboard/frontend/src/App.tsx
- OPTIONAL: Consider wrapping admin-only routes with a permission-aware ProtectedRoute
- If ProtectedRoute already supports group checks, use it
- If not, the per-component check in FahrzeugForm.tsx is sufficient
5. Navigation / sidebar:
- Check if there are navigation links that should be hidden for certain groups
- If a sidebar/drawer component exists, check visibility of menu items
REQUIREMENTS:
- Read each file before modifying
- Do NOT change backend code
- Do NOT change the usePermissions hook signature (but you can add new computed properties)
- Use conditional rendering ({isAdmin && <Component />}) pattern consistently
- For route protection, prefer showing "Keine Berechtigung" over redirecting (less confusing UX)
```
### PROMPT 4: Equipment Database Migration (P4)
```
You are a senior PostgreSQL developer working on a Feuerwehr Dashboard backend.
TASK: Create the database migration for the equipment management system.
CONTEXT:
- Existing migrations are at /Users/matthias/work/feuerwehr_dashboard/backend/src/database/migrations/
- Latest migration is 010_simplify_wartungslog_art.sql
- New migration should be 011_create_ausruestung.sql
- The database uses uuid_generate_v4() for PKs (uuid-ossp extension already enabled)
- Existing pattern: update_updated_at_column() trigger function already exists
- Vehicles table: fahrzeuge (with soft-delete via deleted_at)
CREATE FILE: /Users/matthias/work/feuerwehr_dashboard/backend/src/database/migrations/011_create_ausruestung.sql
SCHEMA:
1. TABLE ausruestung_kategorien:
- id UUID PK DEFAULT uuid_generate_v4()
- name VARCHAR(100) NOT NULL UNIQUE
- kurzname VARCHAR(30) NOT NULL UNIQUE
- sortierung INTEGER NOT NULL DEFAULT 0
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
2. TABLE ausruestung:
- id UUID PK DEFAULT uuid_generate_v4()
- bezeichnung VARCHAR(200) NOT NULL
- kategorie_id UUID NOT NULL FK → ausruestung_kategorien(id)
- seriennummer VARCHAR(100)
- inventarnummer VARCHAR(50)
- hersteller VARCHAR(150)
- baujahr INTEGER CHECK (1950-2100)
- status VARCHAR(30) NOT NULL DEFAULT 'einsatzbereit' CHECK IN ('einsatzbereit','beschaedigt','in_wartung','ausser_dienst')
- status_bemerkung TEXT
- ist_wichtig BOOLEAN NOT NULL DEFAULT FALSE
- fahrzeug_id UUID FK → fahrzeuge(id) ON DELETE SET NULL (nullable)
- standort VARCHAR(150) NOT NULL DEFAULT 'Lager'
- pruef_intervall_monate INTEGER CHECK > 0
- letzte_pruefung_am DATE
- naechste_pruefung_am DATE
- bemerkung TEXT
- deleted_at TIMESTAMPTZ
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
3. TABLE ausruestung_wartungslog:
- id UUID PK DEFAULT uuid_generate_v4()
- ausruestung_id UUID NOT NULL FK → ausruestung(id) ON DELETE CASCADE
- datum DATE NOT NULL
- art VARCHAR(30) NOT NULL CHECK IN ('Prüfung','Reparatur','Sonstiges')
- beschreibung TEXT NOT NULL
- ergebnis VARCHAR(30) CHECK IN ('bestanden','bestanden_mit_maengeln','nicht_bestanden')
- kosten DECIMAL(8,2) CHECK >= 0
- pruefende_stelle VARCHAR(150)
- dokument_url VARCHAR(500)
- erfasst_von UUID FK → users(id) ON DELETE SET NULL
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
4. VIEW ausruestung_mit_pruefstatus:
- Join ausruestung + kategorien + fahrzeuge (LEFT JOIN)
- Compute pruefung_tage_bis_faelligkeit
- Filter WHERE deleted_at IS NULL
5. INDEXES: status, kategorie, fahrzeug, active (partial WHERE deleted_at IS NULL), pruefung, wichtig (partial composite)
6. TRIGGER: update_updated_at_column on ausruestung
7. SEED DATA for categories:
Atemschutzgeräte (PA), Pumpen (Pumpe), Schläuche (SL), Leitern (Leiter),
Rettungsgeräte (RG), Messgeräte (MG), PSA (PSA), Kommunikation (Funk),
Beleuchtung (Licht), Sonstige (Sonst.)
Follow the exact SQL style of existing migrations (read 005_create_fahrzeuge.sql for reference).
```
### PROMPT 5: Equipment Backend (P5)
```
You are a senior Node.js/TypeScript developer working on a Feuerwehr Dashboard backend.
TASK: Create the complete backend for equipment management (model, service, controller, routes).
CONTEXT:
- Express 5 + TypeScript + PostgreSQL (raw SQL via pg pool, no ORM)
- Read existing vehicle implementation as the pattern to follow:
- Model: /backend/src/models/vehicle.model.ts
- Service: /backend/src/services/vehicle.service.ts
- Controller: /backend/src/controllers/vehicle.controller.ts
- Routes: /backend/src/routes/vehicle.routes.ts
- Database tables: ausruestung, ausruestung_kategorien, ausruestung_wartungslog (created by migration 011)
- View: ausruestung_mit_pruefstatus
FILES TO CREATE:
1. /backend/src/models/equipment.model.ts
- Enums: AusruestungStatus, AusruestungWartungslogArt
- Interfaces: AusruestungKategorie, Ausruestung, AusruestungListItem, AusruestungDetail,
AusruestungWartungslog, EquipmentStats, VehicleEquipmentWarning
- DTOs: CreateAusruestungData, UpdateAusruestungData, CreateAusruestungWartungslogData
2. /backend/src/services/equipment.service.ts
Methods:
- getAllEquipment() → SELECT from view ausruestung_mit_pruefstatus
- getEquipmentById(id) → detail + wartungslog
- getEquipmentByVehicle(fahrzeugId) → equipment assigned to vehicle
- getCategories() → all categories ordered by sortierung
- createEquipment(data, createdBy) → INSERT into ausruestung
- updateEquipment(id, data, updatedBy) → dynamic PATCH
- deleteEquipment(id, deletedBy) → SET deleted_at
- updateStatus(id, status, bemerkung, updatedBy)
- addWartungslog(equipmentId, data, createdBy)
- getEquipmentStats() → counts by status + inspection alerts
- getVehicleWarnings() → important items not einsatzbereit, grouped by fahrzeug
- getUpcomingInspections(daysAhead)
3. /backend/src/controllers/equipment.controller.ts
- Zod validation schemas (CreateAusruestungSchema, UpdateAusruestungSchema, etc.)
- Request handlers matching service methods
- Standard { success: true, data: ... } response envelope
- Error handling with try/catch and appropriate HTTP status codes
4. /backend/src/routes/equipment.routes.ts
- GET / (list), GET /stats, GET /alerts, GET /categories
- GET /vehicle-warnings, GET /vehicle/:fahrzeugId
- GET /:id (detail)
- POST / (create, requireGroups admin+fahrmeister)
- PATCH /:id (update, requireGroups admin+fahrmeister)
- PATCH /:id/status (status change, requireGroups admin+fahrmeister)
- POST /:id/wartung (add log, requireGroups admin+fahrmeister)
- DELETE /:id (soft-delete, requireGroups admin only)
5. MODIFY /backend/src/app.ts
- Add: import equipmentRoutes from './routes/equipment.routes'
- Add: app.use('/api/equipment', equipmentRoutes)
IMPORTANT:
- Follow EXACT patterns from vehicle implementation (pool.query, error handling, logging)
- Use the database pool import from existing config
- Use authenticate and requireGroups middleware from existing middleware
- Read the vehicle files first to understand all patterns before writing
```
### PROMPT 6: Equipment Frontend Types + API Service (P6a)
```
You are a senior React/TypeScript developer working on a Feuerwehr Dashboard.
TASK: Create the frontend TypeScript types and API service for equipment management.
CONTEXT:
- Follow the exact patterns from vehicle types and service:
- Types: /frontend/src/types/vehicle.types.ts
- Service: /frontend/src/services/vehicles.ts
- API client: /frontend/src/services/api.ts (Axios instance)
FILES TO CREATE:
1. /frontend/src/types/equipment.types.ts
- AusruestungStatus enum (einsatzbereit, beschaedigt, in_wartung, ausser_dienst)
- AusruestungKategorie interface
- AusruestungListItem interface (from view, includes kategorie_name, fahrzeug_bezeichnung, pruefung_tage_bis_faelligkeit)
- AusruestungDetail interface (extends with wartungslog array)
- AusruestungWartungslog interface
- EquipmentStats interface
- VehicleEquipmentWarning interface
- Create/Update payload types
- All Date fields as string (JSON serialization)
2. /frontend/src/services/equipment.ts
Methods (using api instance and unwrap pattern from vehicles.ts):
- getAll() → AusruestungListItem[]
- getById(id) → AusruestungDetail
- getByVehicle(fahrzeugId) → AusruestungListItem[]
- getCategories() → AusruestungKategorie[]
- getStats() → EquipmentStats
- getVehicleWarnings() → VehicleEquipmentWarning[]
- getAlerts(daysAhead) → InspectionAlert-like array
- create(payload) → AusruestungDetail
- update(id, payload) → AusruestungDetail
- delete(id) → void
- updateStatus(id, payload) → void
- addWartungslog(id, payload) → AusruestungWartungslog
Read the vehicle files first to match patterns exactly.
```
### PROMPT 7: Equipment List Page (P6b)
```
You are a senior React/TypeScript developer building a Feuerwehr Dashboard with React 18 + MUI 5 + TypeScript.
TASK: Replace the placeholder Ausruestung.tsx with a full equipment list page.
CONTEXT:
- Existing placeholder: /frontend/src/pages/Ausruestung.tsx (just shows "coming soon")
- Follow the pattern of /frontend/src/pages/Fahrzeuge.tsx (vehicle list with cards, search, filters)
- API service: equipmentApi from /frontend/src/services/equipment.ts
- Types: from /frontend/src/types/equipment.types.ts
- Permissions: usePermissions() → { isAdmin, canChangeStatus, canManageEquipment }
- Layout: DashboardLayout wrapper
- Routing: clicking an item navigates to /ausruestung/:id
REPLACE FILE: /frontend/src/pages/Ausruestung.tsx
REQUIREMENTS:
1. Stats bar at top:
- Total equipment count, einsatzbereit count, beschädigt count, prüfung fällig count
- Use Chip or small stat cards
2. Filters:
- Search field (search across bezeichnung, seriennummer, inventarnummer, hersteller)
- Category dropdown (populated from equipmentApi.getCategories())
- Status dropdown (all 4 statuses)
- Checkbox: "Nur wichtige" (filter ist_wichtig)
- Checkbox: "Prüfung fällig" (filter pruefung_tage_bis_faelligkeit <= 30)
3. Table/List view (use MUI Table or Card grid matching Fahrzeuge.tsx pattern):
- Columns: Bezeichnung, Kategorie, Seriennr., Fahrzeug/Standort, Status (chip), Nächste Prüfung
- Status chips with color coding:
- einsatzbereit → success (green)
- beschaedigt → error (red)
- in_wartung → warning (orange)
- ausser_dienst → default (grey)
- Prüfung overdue → red text/chip
- Click row → navigate to /ausruestung/:id
- Important items: show star/flag icon
4. FAB button to add new equipment:
- Only visible to canManageEquipment users
- Navigates to /ausruestung/neu
5. Loading state with Skeleton/CircularProgress
6. Error state with retry button
7. Empty state message
Read Fahrzeuge.tsx first to match the exact code style, imports, and patterns.
```
### PROMPT 8: Equipment Form Page (P7)
```
You are a senior React/TypeScript developer building a Feuerwehr Dashboard.
TASK: Create the equipment create/edit form page.
CONTEXT:
- Follow the exact pattern of /frontend/src/pages/FahrzeugForm.tsx
- Route: /ausruestung/neu (create) and /ausruestung/:id/bearbeiten (edit)
- API: equipmentApi from /frontend/src/services/equipment.ts
- Types from /frontend/src/types/equipment.types.ts
- Permission: only canManageEquipment users should see this
CREATE FILE: /frontend/src/pages/AusruestungForm.tsx
FORM FIELDS:
- bezeichnung (required, TextField)
- kategorie_id (required, Select dropdown from equipmentApi.getCategories())
- seriennummer (TextField)
- inventarnummer (TextField)
- hersteller (TextField)
- baujahr (number input, 1950-2100)
- status (Select: einsatzbereit, beschaedigt, in_wartung, ausser_dienst)
- status_bemerkung (TextField multiline)
- ist_wichtig (Checkbox/Switch with label "Wichtiges Gerät (Warnung auf Fahrzeugkarte)")
- fahrzeug_id (Select dropdown, populated from vehiclesApi.getAll(), show "Kein Fahrzeug" option)
- standort (TextField, shown when no fahrzeug_id selected)
- pruef_intervall_monate (number input)
- letzte_pruefung_am (date input)
- naechste_pruefung_am (date input)
- bemerkung (TextField multiline)
BEHAVIOR:
- Create mode: empty form, POST on submit
- Edit mode: fetch equipment by id, pre-populate all fields, PATCH on submit
- Permission check: show "Keine Berechtigung" if not canManageEquipment
- Navigate back to /ausruestung or /ausruestung/:id on success
- Validation: bezeichnung required, kategorie_id required
- Loading states during fetch and submit
Read FahrzeugForm.tsx first to match patterns exactly.
```
### PROMPT 9: Equipment Detail Page (P8)
```
You are a senior React/TypeScript developer building a Feuerwehr Dashboard.
TASK: Create the equipment detail page with tabs.
CONTEXT:
- Follow the pattern of /frontend/src/pages/FahrzeugDetail.tsx
- Route: /ausruestung/:id
- API: equipmentApi from /frontend/src/services/equipment.ts
CREATE FILE: /frontend/src/pages/AusruestungDetail.tsx
STRUCTURE (2 tabs):
Tab 1 — Übersicht:
- Header with bezeichnung, edit button (canManageEquipment), delete button (isAdmin)
- Status panel with current status chip + change button (canManageEquipment)
- Status change dialog (select new status + bemerkung)
- Data grid showing: Kategorie, Seriennummer, Inventarnummer, Hersteller, Baujahr,
Fahrzeug (link to /fahrzeuge/:fahrzeug_id), Standort, Wichtig flag,
Prüfintervall, Letzte Prüfung, Nächste Prüfung (with days-until color coding)
- Delete confirmation dialog (same pattern as vehicle delete from P2)
Tab 2 — Wartung:
- Timeline/list of wartungslog entries
- Each entry shows: datum, art (chip), beschreibung, ergebnis (chip), kosten, pruefende_stelle
- Add wartungslog form (canManageEquipment only):
- datum (date), art (select), beschreibung (text), ergebnis (select optional),
kosten (number optional), pruefende_stelle (text optional)
- Sorted by date DESC
PATTERNS:
- Read FahrzeugDetail.tsx first and follow its exact structure
- Use same notification pattern for success/error
- Use useParams() for id, useNavigate() for navigation
- Loading/error states
```
### PROMPT 10: Vehicle-Equipment Integration (P9 + P10)
```
You are a senior React/TypeScript developer working on a Feuerwehr Dashboard.
TASK: Integrate equipment into the vehicle pages.
CONTEXT:
- Equipment API: equipmentApi from /frontend/src/services/equipment.ts
- Equipment types from /frontend/src/types/equipment.types.ts
- Vehicle pages to modify:
- /frontend/src/pages/FahrzeugDetail.tsx (add equipment tab)
- /frontend/src/pages/Fahrzeuge.tsx (add warning badges on cards)
PART A — Vehicle Detail Equipment Tab:
MODIFY: /frontend/src/pages/FahrzeugDetail.tsx
1. Add a new tab "Ausrüstung" between existing tabs (after the overview/maintenance tabs)
2. Tab label: "Ausrüstung" with a count badge showing number of assigned items
3. Tab content:
- Fetch equipment via equipmentApi.getByVehicle(vehicleId)
- Show table/list of assigned equipment:
- Bezeichnung, Kategorie, Status (chip), Nächste Prüfung
- Status chips with color coding (green/red/orange/grey)
- Important items marked with star icon
- Click navigates to /ausruestung/:id
- Empty state: "Keine Ausrüstung zugewiesen"
- Link to /ausruestung page
PART B — Vehicle Card Warning Badges:
MODIFY: /frontend/src/pages/Fahrzeuge.tsx
1. On mount, fetch equipmentApi.getVehicleWarnings() alongside the vehicle list
2. Group warnings by fahrzeug_id into a Map
3. In VehicleCard component, below existing inspection badges:
- If vehicle has warnings, show a Chip:
- "1 Ausrüstung beschädigt" (red/error color) or
- "2 Ausrüstung nicht bereit" (orange/warning color)
- Tooltip showing individual item names
4. Only show if warnings array for that vehicle is non-empty
IMPORTANT:
- Read both files completely before making changes
- Don't break existing tab indexing in FahrzeugDetail
- Handle loading states gracefully (don't block vehicle loading)
- Equipment fetch failures should not break the vehicle page (catch errors silently)
```
### PROMPT 11: Routing Updates (included in P6b-P9)
```
You are a senior React/TypeScript developer.
TASK: Add equipment routes to the React Router configuration.
MODIFY: /Users/matthias/work/feuerwehr_dashboard/frontend/src/App.tsx
ADD these routes inside the existing <Routes>, near the existing /ausruestung route:
- /ausruestung → Ausruestung (already exists, will use new component)
- /ausruestung/neu → AusruestungForm (new)
- /ausruestung/:id/bearbeiten → AusruestungForm (new)
- /ausruestung/:id → AusruestungDetail (new)
ALSO ADD:
- /ausruestung/neu BEFORE /ausruestung/:id (so "neu" isn't matched as an :id)
Add imports for AusruestungDetail and AusruestungForm at the top of the file.
Wrap each route in <ProtectedRoute> following the existing pattern.
ALSO MODIFY: /frontend/src/hooks/usePermissions.ts
- Add: canManageEquipment: groups.includes('dashboard_admin') || groups.includes('dashboard_fahrmeister')
```
---
## Step-by-Step Instructions for Matthias
### Phase 1: Vehicle Cleanup (no backend changes, no Docker needed)
**Step 1.1 — Remove info fields**
- Run subagent with Prompt 1
- Files changed: FahrzeugForm.tsx, FahrzeugDetail.tsx, Fahrzeuge.tsx
- Test: Open vehicle form/detail in browser, verify fields are gone
- Commit: `feat: remove Fahrgestellnr, Standort, Besatzung, Typ, Hersteller, Baujahr from vehicle UI`
**Step 1.2 — Add delete vehicle UI**
- Run subagent with Prompt 2
- Files changed: FahrzeugDetail.tsx
- Test: Log in as admin, open vehicle detail, click delete, verify dialog, cancel, then test actual delete
- Commit: `feat: add delete button with confirmation dialog to vehicle detail page`
**Step 1.3 — Permission guards**
- Run subagent with Prompt 3
- Files changed: FahrzeugDetail.tsx, FahrzeugForm.tsx, possibly App.tsx
- Test: Log in as non-admin user, verify add/edit/delete buttons are hidden, try direct URL to /fahrzeuge/neu
- Commit: `feat: hide restricted vehicle features from unauthorized user groups`
### Phase 2: Equipment Backend (requires Docker/PostgreSQL for migration)
**Step 2.1 — Create database migration**
- Run subagent with Prompt 4
- File created: backend/src/database/migrations/011_create_ausruestung.sql
- Action needed: Run migration against your PostgreSQL database
```bash
# Connect to your database and run:
psql -U <user> -d <database> -f backend/src/database/migrations/011_create_ausruestung.sql
```
- Verify: Check that tables ausruestung, ausruestung_kategorien, ausruestung_wartungslog exist
- Verify: Check that view ausruestung_mit_pruefstatus works
- Verify: Check that seed categories were inserted
- Commit: `feat: add equipment management database schema (migration 011)`
**Step 2.2 — Create backend model + service + controller + routes**
- Run subagent with Prompt 5
- Files created: equipment.model.ts, equipment.service.ts, equipment.controller.ts, equipment.routes.ts
- File modified: app.ts (add route registration)
- Test: Start backend, test endpoints with curl/Postman:
```bash
# Get categories
curl -H "Authorization: Bearer <token>" http://localhost:3000/api/equipment/categories
# Create equipment
curl -X POST -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{"bezeichnung":"Test Gerät","kategorie_id":"<uuid>"}' \
http://localhost:3000/api/equipment
```
- Commit: `feat: add equipment management backend (model, service, controller, routes)`
### Phase 3: Equipment Frontend (no backend changes)
**Step 3.1 — Create frontend types + API service**
- Run subagent with Prompt 6
- Files created: equipment.types.ts, equipment.ts (service)
- Commit: `feat: add equipment TypeScript types and API service`
**Step 3.2 — Create equipment list page**
- Run subagent with Prompt 7
- File replaced: Ausruestung.tsx
- Test: Navigate to /ausruestung, verify list loads (empty state initially)
- Commit: `feat: replace equipment placeholder with full list page`
**Step 3.3 — Create equipment form**
- Run subagent with Prompt 8
- File created: AusruestungForm.tsx
- Test: Navigate to /ausruestung/neu, fill form, submit
- Commit: `feat: add equipment create/edit form page`
**Step 3.4 — Create equipment detail page**
- Run subagent with Prompt 9
- File created: AusruestungDetail.tsx
- Test: Click on equipment in list, verify detail page with tabs
- Commit: `feat: add equipment detail page with tabs`
**Step 3.5 — Update routing + permissions**
- Run subagent with Prompt 11
- Files modified: App.tsx, usePermissions.ts
- Test: Navigate between equipment pages, verify routing works
- Commit: `feat: add equipment routes and canManageEquipment permission`
### Phase 4: Integration (modifies vehicle pages)
**Step 4.1 — Vehicle-equipment integration**
- Run subagent with Prompt 10
- Files modified: FahrzeugDetail.tsx, Fahrzeuge.tsx
- Test:
1. Assign equipment to a vehicle (via equipment form, set fahrzeug_id)
2. Open vehicle detail → verify "Ausrüstung" tab shows the items
3. Set an important equipment item to "beschädigt"
4. Open vehicle list → verify warning badge appears on that vehicle's card
- Commit: `feat: add equipment tab to vehicle detail and warning badges to vehicle cards`
### Final Verification Checklist
- [ ] Vehicle add works (admin only)
- [ ] Vehicle edit works (admin only)
- [ ] Vehicle delete works with confirmation (admin only)
- [ ] Non-admin users cannot see add/edit/delete buttons
- [ ] Non-admin users get "Keine Berechtigung" when accessing restricted routes directly
- [ ] Removed fields (Fahrgestellnr, Standort, Besatzung, Typ, Hersteller, Baujahr) not visible
- [ ] Equipment categories load correctly
- [ ] Equipment CRUD works (create, read, update, soft-delete)
- [ ] Equipment list page with filters works
- [ ] Equipment detail page with tabs works
- [ ] Equipment wartungslog can be added
- [ ] Vehicle detail shows equipment tab with assigned items
- [ ] Vehicle cards show warning badges for non-ready important equipment
- [ ] Permission guards work: equipment management restricted to admin + fahrmeister