refine vehicle freatures
This commit is contained in:
@@ -60,6 +60,7 @@ import adminRoutes from './routes/admin.routes';
|
|||||||
import trainingRoutes from './routes/training.routes';
|
import trainingRoutes from './routes/training.routes';
|
||||||
import vehicleRoutes from './routes/vehicle.routes';
|
import vehicleRoutes from './routes/vehicle.routes';
|
||||||
import incidentRoutes from './routes/incident.routes';
|
import incidentRoutes from './routes/incident.routes';
|
||||||
|
import equipmentRoutes from './routes/equipment.routes';
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/user', userRoutes);
|
app.use('/api/user', userRoutes);
|
||||||
@@ -68,6 +69,7 @@ app.use('/api/admin', adminRoutes);
|
|||||||
app.use('/api/training', trainingRoutes);
|
app.use('/api/training', trainingRoutes);
|
||||||
app.use('/api/vehicles', vehicleRoutes);
|
app.use('/api/vehicles', vehicleRoutes);
|
||||||
app.use('/api/incidents', incidentRoutes);
|
app.use('/api/incidents', incidentRoutes);
|
||||||
|
app.use('/api/equipment', equipmentRoutes);
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
|
|||||||
318
backend/src/controllers/equipment.controller.ts
Normal file
318
backend/src/controllers/equipment.controller.ts
Normal 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();
|
||||||
143
backend/src/database/migrations/011_create_ausruestung.sql
Normal file
143
backend/src/database/migrations/011_create_ausruestung.sql
Normal 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;
|
||||||
175
backend/src/models/equipment.model.ts
Normal file
175
backend/src/models/equipment.model.ts
Normal 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;
|
||||||
|
}
|
||||||
32
backend/src/routes/equipment.routes.ts
Normal file
32
backend/src/routes/equipment.routes.ts
Normal 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;
|
||||||
414
backend/src/services/equipment.service.ts
Normal file
414
backend/src/services/equipment.service.ts
Normal 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();
|
||||||
@@ -14,6 +14,8 @@ import Fahrzeuge from './pages/Fahrzeuge';
|
|||||||
import FahrzeugDetail from './pages/FahrzeugDetail';
|
import FahrzeugDetail from './pages/FahrzeugDetail';
|
||||||
import FahrzeugForm from './pages/FahrzeugForm';
|
import FahrzeugForm from './pages/FahrzeugForm';
|
||||||
import Ausruestung from './pages/Ausruestung';
|
import Ausruestung from './pages/Ausruestung';
|
||||||
|
import AusruestungForm from './pages/AusruestungForm';
|
||||||
|
import AusruestungDetail from './pages/AusruestungDetail';
|
||||||
import Mitglieder from './pages/Mitglieder';
|
import Mitglieder from './pages/Mitglieder';
|
||||||
import MitgliedDetail from './pages/MitgliedDetail';
|
import MitgliedDetail from './pages/MitgliedDetail';
|
||||||
import Kalender from './pages/Kalender';
|
import Kalender from './pages/Kalender';
|
||||||
@@ -109,6 +111,30 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</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
|
<Route
|
||||||
path="/mitglieder"
|
path="/mitglieder"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export function usePermissions() {
|
|||||||
return {
|
return {
|
||||||
isAdmin: groups.includes('dashboard_admin'),
|
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,
|
groups,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +1,478 @@
|
|||||||
|
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Container,
|
Alert,
|
||||||
Typography,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardActionArea,
|
||||||
|
CardContent,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
Fab,
|
||||||
|
FormControl,
|
||||||
|
FormControlLabel,
|
||||||
|
Grid,
|
||||||
|
InputAdornment,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
} from '@mui/material';
|
} 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 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() {
|
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 (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
|
{/* Header */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
|
||||||
Ausrüstungsverwaltung
|
Ausrüstungsverwaltung
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{!loading && stats && (
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 0.5 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{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>
|
||||||
|
|
||||||
<Card>
|
{/* Overdue alert */}
|
||||||
<CardContent>
|
{hasOverdue && (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
<Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}>
|
||||||
<Build color="primary" sx={{ fontSize: 48, mr: 2 }} />
|
<strong>Achtung:</strong> Mindestens eine Ausrüstung hat eine überfällige Prüfungsfrist.
|
||||||
<Box>
|
</Alert>
|
||||||
<Typography variant="h6">Ausrüstung</Typography>
|
)}
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Diese Funktion wird in Kürze verfügbar sein
|
{/* 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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
)}
|
||||||
<Box sx={{ mt: 3 }}>
|
|
||||||
<Typography variant="body1" color="text.secondary" paragraph>
|
{/* Equipment grid */}
|
||||||
Geplante Features:
|
{!loading && !error && filtered.length > 0 && (
|
||||||
</Typography>
|
<Grid container spacing={3}>
|
||||||
<ul>
|
{filtered.map((item) => (
|
||||||
<li>
|
<Grid item key={item.id} xs={12} sm={6} md={4} lg={3}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<EquipmentCard
|
||||||
Inventarverwaltung
|
item={item}
|
||||||
</Typography>
|
onClick={(id) => navigate(`/ausruestung/${id}`)}
|
||||||
</li>
|
/>
|
||||||
<li>
|
</Grid>
|
||||||
<Typography variant="body2" color="text.secondary">
|
))}
|
||||||
Wartungsprüfungen und -protokolle
|
</Grid>
|
||||||
</Typography>
|
)}
|
||||||
</li>
|
|
||||||
<li>
|
{/* FAB for adding new equipment */}
|
||||||
<Typography variant="body2" color="text.secondary">
|
{canManageEquipment && (
|
||||||
Prüffristen und Erinnerungen
|
<Fab
|
||||||
</Typography>
|
color="primary"
|
||||||
</li>
|
aria-label="Ausrüstung hinzufügen"
|
||||||
<li>
|
sx={{ position: 'fixed', bottom: 32, right: 32 }}
|
||||||
<Typography variant="body2" color="text.secondary">
|
onClick={() => navigate('/ausruestung/neu')}
|
||||||
Schutzausrüstung (PSA)
|
>
|
||||||
</Typography>
|
<Add />
|
||||||
</li>
|
</Fab>
|
||||||
<li>
|
)}
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Atemschutzgeräte und -wartung
|
|
||||||
</Typography>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</Box>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Container>
|
</Container>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
762
frontend/src/pages/AusruestungDetail.tsx
Normal file
762
frontend/src/pages/AusruestungDetail.tsx
Normal 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 '{equipment.bezeichnung}' 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;
|
||||||
521
frontend/src/pages/AusruestungForm.tsx
Normal file
521
frontend/src/pages/AusruestungForm.tsx
Normal 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 & 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 & 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;
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Dialog,
|
Dialog,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
Divider,
|
Divider,
|
||||||
Fab,
|
Fab,
|
||||||
@@ -16,11 +17,18 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
IconButton,
|
IconButton,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
|
Link,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Paper,
|
Paper,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Tab,
|
Tab,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
Tabs,
|
Tabs,
|
||||||
TextField,
|
TextField,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -32,6 +40,7 @@ import {
|
|||||||
Assignment,
|
Assignment,
|
||||||
Build,
|
Build,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
DeleteOutline,
|
||||||
DirectionsCar,
|
DirectionsCar,
|
||||||
Edit,
|
Edit,
|
||||||
Error as ErrorIcon,
|
Error as ErrorIcon,
|
||||||
@@ -40,12 +49,14 @@ import {
|
|||||||
PauseCircle,
|
PauseCircle,
|
||||||
ReportProblem,
|
ReportProblem,
|
||||||
School,
|
School,
|
||||||
|
Star,
|
||||||
Verified,
|
Verified,
|
||||||
Warning,
|
Warning,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { vehiclesApi } from '../services/vehicles';
|
import { vehiclesApi } from '../services/vehicles';
|
||||||
|
import { equipmentApi } from '../services/equipment';
|
||||||
import {
|
import {
|
||||||
FahrzeugDetail,
|
FahrzeugDetail,
|
||||||
FahrzeugWartungslog,
|
FahrzeugWartungslog,
|
||||||
@@ -55,7 +66,10 @@ import {
|
|||||||
UpdateStatusPayload,
|
UpdateStatusPayload,
|
||||||
WartungslogArt,
|
WartungslogArt,
|
||||||
} from '../types/vehicle.types';
|
} from '../types/vehicle.types';
|
||||||
|
import type { AusruestungListItem } from '../types/equipment.types';
|
||||||
|
import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
|
|
||||||
// ── Tab Panel ─────────────────────────────────────────────────────────────────
|
// ── Tab Panel ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -195,12 +209,6 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
|||||||
{ label: 'Bezeichnung', value: vehicle.bezeichnung },
|
{ label: 'Bezeichnung', value: vehicle.bezeichnung },
|
||||||
{ label: 'Kurzname', value: vehicle.kurzname },
|
{ label: 'Kurzname', value: vehicle.kurzname },
|
||||||
{ label: 'Kennzeichen', value: vehicle.amtliches_kennzeichen },
|
{ 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 }) => (
|
].map(({ label, value }) => (
|
||||||
<Grid item xs={12} sm={6} md={4} key={label}>
|
<Grid item xs={12} sm={6} md={4} key={label}>
|
||||||
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
|
<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 ─────────────────────────────────────────────────────────────────
|
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function FahrzeugDetail() {
|
function FahrzeugDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isAdmin, canChangeStatus } = usePermissions();
|
const { isAdmin, canChangeStatus } = usePermissions();
|
||||||
|
const notification = useNotification();
|
||||||
|
|
||||||
const [vehicle, setVehicle] = useState<FahrzeugDetail | null>(null);
|
const [vehicle, setVehicle] = useState<FahrzeugDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState(0);
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
const [vehicleEquipment, setVehicleEquipment] = useState<AusruestungListItem[]>([]);
|
||||||
|
|
||||||
const fetchVehicle = useCallback(async () => {
|
const fetchVehicle = useCallback(async () => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -511,6 +655,13 @@ function FahrzeugDetail() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
const data = await vehiclesApi.getById(id);
|
const data = await vehiclesApi.getById(id);
|
||||||
setVehicle(data);
|
setVehicle(data);
|
||||||
|
// Fetch equipment separately — failure must not break the page
|
||||||
|
try {
|
||||||
|
const eq = await equipmentApi.getByVehicle(id);
|
||||||
|
setVehicleEquipment(eq);
|
||||||
|
} catch {
|
||||||
|
setVehicleEquipment([]);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('Fahrzeug konnte nicht geladen werden.');
|
setError('Fahrzeug konnte nicht geladen werden.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -520,6 +671,20 @@ function FahrzeugDetail() {
|
|||||||
|
|
||||||
useEffect(() => { fetchVehicle(); }, [fetchVehicle]);
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
@@ -573,7 +738,6 @@ function FahrzeugDetail() {
|
|||||||
{vehicle.amtliches_kennzeichen && (
|
{vehicle.amtliches_kennzeichen && (
|
||||||
<Typography variant="subtitle1" color="text.secondary">
|
<Typography variant="subtitle1" color="text.secondary">
|
||||||
{vehicle.amtliches_kennzeichen}
|
{vehicle.amtliches_kennzeichen}
|
||||||
{vehicle.hersteller && ` · ${vehicle.hersteller}`}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -594,6 +758,18 @@ function FahrzeugDetail() {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</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>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -614,6 +790,7 @@ function FahrzeugDetail() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Tab label="Einsätze" />
|
<Tab label="Einsätze" />
|
||||||
|
<Tab label={`Ausrüstung${vehicleEquipment.length > 0 ? ` (${vehicleEquipment.length})` : ''}`} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -645,6 +822,38 @@ function FahrzeugDetail() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</TabPanel>
|
</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 '{vehicle.bezeichnung}' 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>
|
</Container>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
CreateFahrzeugPayload,
|
CreateFahrzeugPayload,
|
||||||
UpdateFahrzeugPayload,
|
UpdateFahrzeugPayload,
|
||||||
} from '../types/vehicle.types';
|
} from '../types/vehicle.types';
|
||||||
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
|
||||||
// ── Form state shape ──────────────────────────────────────────────────────────
|
// ── Form state shape ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -74,8 +75,30 @@ function toDateInput(iso: string | null | undefined): string {
|
|||||||
function FahrzeugForm() {
|
function FahrzeugForm() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
const isEditMode = Boolean(id);
|
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 [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||||
const [loading, setLoading] = useState(isEditMode);
|
const [loading, setLoading] = useState(isEditMode);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -121,9 +144,6 @@ function FahrzeugForm() {
|
|||||||
if (!form.bezeichnung.trim()) {
|
if (!form.bezeichnung.trim()) {
|
||||||
errors.bezeichnung = 'Bezeichnung ist erforderlich.';
|
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);
|
setFieldErrors(errors);
|
||||||
return Object.keys(errors).length === 0;
|
return Object.keys(errors).length === 0;
|
||||||
};
|
};
|
||||||
@@ -261,53 +281,6 @@ function FahrzeugForm() {
|
|||||||
placeholder="z.B. WN-FW 1"
|
placeholder="z.B. WN-FW 1"
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</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>
|
</Grid>
|
||||||
|
|
||||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Status</Typography>
|
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Status</Typography>
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ import {
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { vehiclesApi } from '../services/vehicles';
|
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 {
|
import {
|
||||||
FahrzeugListItem,
|
FahrzeugListItem,
|
||||||
FahrzeugStatus,
|
FahrzeugStatus,
|
||||||
@@ -86,9 +89,10 @@ function inspTooltipTitle(fullLabel: string, tage: number | null, faelligAm: str
|
|||||||
interface VehicleCardProps {
|
interface VehicleCardProps {
|
||||||
vehicle: FahrzeugListItem;
|
vehicle: FahrzeugListItem;
|
||||||
onClick: (id: string) => void;
|
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 status = vehicle.status as FahrzeugStatus;
|
||||||
const statusCfg = STATUS_CONFIG[status] ?? STATUS_CONFIG[FahrzeugStatus.Einsatzbereit];
|
const statusCfg = STATUS_CONFIG[status] ?? STATUS_CONFIG[FahrzeugStatus.Einsatzbereit];
|
||||||
const isSchaden = status === FahrzeugStatus.AusserDienstSchaden;
|
const isSchaden = status === FahrzeugStatus.AusserDienstSchaden;
|
||||||
@@ -183,13 +187,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</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 && (
|
{inspBadges.length > 0 && (
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
{inspBadges.map((b) => {
|
{inspBadges.map((b) => {
|
||||||
@@ -214,6 +211,18 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
|||||||
})}
|
})}
|
||||||
</Box>
|
</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>
|
</CardContent>
|
||||||
</CardActionArea>
|
</CardActionArea>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -229,6 +238,7 @@ function Fahrzeuge() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [equipmentWarnings, setEquipmentWarnings] = useState<Map<string, VehicleEquipmentWarning[]>>(new Map());
|
||||||
|
|
||||||
const fetchVehicles = useCallback(async () => {
|
const fetchVehicles = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -245,6 +255,26 @@ function Fahrzeuge() {
|
|||||||
|
|
||||||
useEffect(() => { fetchVehicles(); }, [fetchVehicles]);
|
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) => {
|
const filtered = vehicles.filter((v) => {
|
||||||
if (!search.trim()) return true;
|
if (!search.trim()) return true;
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
@@ -337,6 +367,7 @@ function Fahrzeuge() {
|
|||||||
<VehicleCard
|
<VehicleCard
|
||||||
vehicle={vehicle}
|
vehicle={vehicle}
|
||||||
onClick={(id) => navigate(`/fahrzeuge/${id}`)}
|
onClick={(id) => navigate(`/fahrzeuge/${id}`)}
|
||||||
|
warnings={equipmentWarnings.get(vehicle.id) || []}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
|
|||||||
111
frontend/src/services/equipment.ts
Normal file
111
frontend/src/services/equipment.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
136
frontend/src/types/equipment.types.ts
Normal file
136
frontend/src/types/equipment.types.ts
Normal 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;
|
||||||
|
}
|
||||||
688
plans/2026-02-28-vehicle-equipment-features.md
Normal file
688
plans/2026-02-28-vehicle-equipment-features.md
Normal 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
|
||||||
Reference in New Issue
Block a user