diff --git a/backend/src/app.ts b/backend/src/app.ts index d327f28..274adb0 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -20,26 +20,28 @@ app.use(cors({ credentials: true, })); -// Rate limiting - general API routes -const limiter = rateLimit({ - windowMs: environment.rateLimit.windowMs, - max: environment.rateLimit.max, - message: 'Too many requests from this IP, please try again later.', - standardHeaders: true, - legacyHeaders: false, -}); +// Rate limiting - general API routes (applied below, after auth limiter) -// Rate limiting - auth routes (more generous to avoid blocking logins) +// Rate limiting - auth routes (generous to avoid blocking logins during +// normal use; each OAuth flow = 1 callback + token exchange) const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes - max: 30, // 30 auth attempts per window + max: 60, // 60 auth attempts per window (allows ~20 full login cycles) message: 'Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.', standardHeaders: true, legacyHeaders: false, }); app.use('/api/auth', authLimiter); -app.use('/api', limiter); +// General rate limiter — skip auth routes (they have their own limiter above) +app.use('/api', rateLimit({ + windowMs: environment.rateLimit.windowMs, + max: environment.rateLimit.max, + message: 'Too many requests from this IP, please try again later.', + standardHeaders: true, + legacyHeaders: false, + skip: (req) => req.path.startsWith('/auth'), +})); // Body parsing middleware app.use(express.json({ limit: '10mb' })); @@ -74,16 +76,18 @@ import trainingRoutes from './routes/training.routes'; import vehicleRoutes from './routes/vehicle.routes'; import incidentRoutes from './routes/incident.routes'; import equipmentRoutes from './routes/equipment.routes'; -import nextcloudRoutes from './routes/nextcloud.routes'; +import nextcloudRoutes from './routes/nextcloud.routes'; +import atemschutzRoutes from './routes/atemschutz.routes'; -app.use('/api/auth', authRoutes); -app.use('/api/user', userRoutes); -app.use('/api/members', memberRoutes); -app.use('/api/admin', adminRoutes); -app.use('/api/training', trainingRoutes); -app.use('/api/vehicles', vehicleRoutes); -app.use('/api/incidents', incidentRoutes); -app.use('/api/equipment', equipmentRoutes); +app.use('/api/auth', authRoutes); +app.use('/api/user', userRoutes); +app.use('/api/members', memberRoutes); +app.use('/api/admin', adminRoutes); +app.use('/api/training', trainingRoutes); +app.use('/api/vehicles', vehicleRoutes); +app.use('/api/incidents', incidentRoutes); +app.use('/api/equipment', equipmentRoutes); +app.use('/api/atemschutz', atemschutzRoutes); app.use('/api/nextcloud/talk', nextcloudRoutes); // 404 handler diff --git a/backend/src/controllers/atemschutz.controller.ts b/backend/src/controllers/atemschutz.controller.ts new file mode 100644 index 0000000..d349e24 --- /dev/null +++ b/backend/src/controllers/atemschutz.controller.ts @@ -0,0 +1,139 @@ +import { Request, Response } from 'express'; +import atemschutzService from '../services/atemschutz.service'; +import { CreateAtemschutzSchema, UpdateAtemschutzSchema } from '../models/atemschutz.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); +} + +// ── Helper ──────────────────────────────────────────────────────────────────── + +function getUserId(req: Request): string { + return req.user!.id; +} + +// ── Controller ──────────────────────────────────────────────────────────────── + +class AtemschutzController { + async list(_req: Request, res: Response): Promise { + try { + const records = await atemschutzService.getAll(); + res.status(200).json({ success: true, data: records }); + } catch (error) { + logger.error('Atemschutz list error', { error }); + res.status(500).json({ success: false, message: 'Atemschutzträger konnten nicht geladen werden' }); + } + } + + async getOne(req: Request, res: Response): Promise { + try { + const { id } = req.params as Record; + if (!isValidUUID(id)) { + res.status(400).json({ success: false, message: 'Ungültige Atemschutz-ID' }); + return; + } + const record = await atemschutzService.getById(id); + if (!record) { + res.status(404).json({ success: false, message: 'Atemschutzträger nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: record }); + } catch (error) { + logger.error('Atemschutz getOne error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Atemschutzträger konnte nicht geladen werden' }); + } + } + + async getStats(_req: Request, res: Response): Promise { + try { + const stats = await atemschutzService.getStats(); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + logger.error('Atemschutz getStats error', { error }); + res.status(500).json({ success: false, message: 'Atemschutz-Statistiken konnten nicht geladen werden' }); + } + } + + async create(req: Request, res: Response): Promise { + try { + const parsed = CreateAtemschutzSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ + success: false, + message: 'Validierungsfehler', + errors: parsed.error.flatten().fieldErrors, + }); + return; + } + const record = await atemschutzService.create(parsed.data, getUserId(req)); + res.status(201).json({ success: true, data: record }); + } catch (error: any) { + if (error?.message?.includes('bereits ein Atemschutz-Eintrag')) { + res.status(409).json({ success: false, message: error.message }); + return; + } + logger.error('Atemschutz create error', { error }); + res.status(500).json({ success: false, message: 'Atemschutzträger konnte nicht erstellt werden' }); + } + } + + async update(req: Request, res: Response): Promise { + try { + const { id } = req.params as Record; + if (!isValidUUID(id)) { + res.status(400).json({ success: false, message: 'Ungültige Atemschutz-ID' }); + return; + } + const parsed = UpdateAtemschutzSchema.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 record = await atemschutzService.update(id, parsed.data, getUserId(req)); + if (!record) { + res.status(404).json({ success: false, message: 'Atemschutzträger nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: record }); + } 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('Atemschutz update error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Atemschutzträger konnte nicht aktualisiert werden' }); + } + } + + async delete(req: Request, res: Response): Promise { + try { + const { id } = req.params as Record; + if (!isValidUUID(id)) { + res.status(400).json({ success: false, message: 'Ungültige Atemschutz-ID' }); + return; + } + const deleted = await atemschutzService.delete(id, getUserId(req)); + if (!deleted) { + res.status(404).json({ success: false, message: 'Atemschutzträger nicht gefunden' }); + return; + } + res.status(200).json({ success: true, message: 'Atemschutzträger gelöscht' }); + } catch (error) { + logger.error('Atemschutz delete error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Atemschutzträger konnte nicht gelöscht werden' }); + } + } +} + +export default new AtemschutzController(); diff --git a/backend/src/database/migrations/014_create_atemschutz.sql b/backend/src/database/migrations/014_create_atemschutz.sql new file mode 100644 index 0000000..44346b7 --- /dev/null +++ b/backend/src/database/migrations/014_create_atemschutz.sql @@ -0,0 +1,129 @@ +-- Migration: 014_create_atemschutz +-- Atemschutz-Traegerverwaltung (Breathing Apparatus Carrier Management) +-- Depends on: 001_create_users_table (users table, uuid-ossp, update_updated_at_column) +-- 003_create_mitglieder_profile (mitglieder_profile for the view) +-- Rollback: +-- DROP VIEW IF EXISTS atemschutz_uebersicht; +-- DROP TABLE IF EXISTS atemschutz_traeger; + +-- ============================================================ +-- TABLE: atemschutz_traeger (BA Carrier Registry) +-- Each row = one firefighter qualified/tracked for BA use. +-- One record per user (enforced by UNIQUE constraint). +-- ============================================================ +CREATE TABLE IF NOT EXISTS atemschutz_traeger ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Atemschutz-Lehrgang (BA course qualification) + atemschutz_lehrgang BOOLEAN NOT NULL DEFAULT FALSE, + lehrgang_datum DATE, + + -- G26.3 Aerztliche Untersuchung (doctor's examination) + untersuchung_datum DATE, + untersuchung_gueltig_bis DATE, -- typically valid 3 years + untersuchung_ergebnis VARCHAR(30) + CHECK (untersuchung_ergebnis IS NULL OR untersuchung_ergebnis IN ( + 'tauglich', 'bedingt_tauglich', 'nicht_tauglich' + )), + + -- Leistungstest / Finnentest (performance test) + leistungstest_datum DATE, + leistungstest_gueltig_bis DATE, -- typically valid 1 year + leistungstest_bestanden BOOLEAN, + + -- Free-text notes (Kommandant / Atemschutzwart) + bemerkung TEXT, + + -- Audit timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + -- One record per user + CONSTRAINT uq_atemschutz_user UNIQUE (user_id) +); + +-- ============================================================ +-- Indexes for the most common query patterns +-- ============================================================ +CREATE INDEX IF NOT EXISTS idx_atemschutz_user + ON atemschutz_traeger(user_id); + +CREATE INDEX IF NOT EXISTS idx_atemschutz_untersuchung + ON atemschutz_traeger(untersuchung_gueltig_bis) + WHERE untersuchung_gueltig_bis IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_atemschutz_leistungstest + ON atemschutz_traeger(leistungstest_gueltig_bis) + WHERE leistungstest_gueltig_bis IS NOT NULL; + +-- ============================================================ +-- Auto-update trigger for updated_at +-- Reuses the function already created by migration 001. +-- ============================================================ +CREATE TRIGGER update_atemschutz_updated_at + BEFORE UPDATE ON atemschutz_traeger + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- ============================================================ +-- VIEW: atemschutz_uebersicht +-- Dashboard view with user info and computed validity status. +-- Joins user + mitglieder_profile for display name, rank, etc. +-- Computes expiry flags so the frontend can render traffic-light +-- indicators without any date math on the client side. +-- ============================================================ +CREATE OR REPLACE VIEW atemschutz_uebersicht AS +SELECT + at.*, + u.name AS user_name, + u.given_name AS user_given_name, + u.family_name AS user_family_name, + u.email AS user_email, + mp.status AS mitglied_status, + mp.dienstgrad, + + -- Computed: is the medical exam still valid? + CASE + WHEN at.untersuchung_gueltig_bis IS NOT NULL + THEN at.untersuchung_gueltig_bis >= CURRENT_DATE + ELSE FALSE + END AS untersuchung_gueltig, + + -- Computed: days until medical exam expires (negative = expired) + CASE + WHEN at.untersuchung_gueltig_bis IS NOT NULL + THEN at.untersuchung_gueltig_bis::date - CURRENT_DATE + ELSE NULL + END AS untersuchung_tage_rest, + + -- Computed: is the performance test still valid? + CASE + WHEN at.leistungstest_gueltig_bis IS NOT NULL + THEN at.leistungstest_gueltig_bis >= CURRENT_DATE + ELSE FALSE + END AS leistungstest_gueltig, + + -- Computed: days until performance test expires (negative = expired) + CASE + WHEN at.leistungstest_gueltig_bis IS NOT NULL + THEN at.leistungstest_gueltig_bis::date - CURRENT_DATE + ELSE NULL + END AS leistungstest_tage_rest, + + -- Computed: fully qualified for BA use (einsatzbereit)? + -- Requires: course done + valid medical (tauglich) + valid performance test (bestanden) + CASE + WHEN at.atemschutz_lehrgang = TRUE + AND at.untersuchung_gueltig_bis IS NOT NULL + AND at.untersuchung_gueltig_bis >= CURRENT_DATE + AND at.untersuchung_ergebnis = 'tauglich' + AND at.leistungstest_gueltig_bis IS NOT NULL + AND at.leistungstest_gueltig_bis >= CURRENT_DATE + AND at.leistungstest_bestanden = TRUE + THEN TRUE + ELSE FALSE + END AS einsatzbereit + +FROM atemschutz_traeger at +JOIN users u ON u.id = at.user_id +LEFT JOIN mitglieder_profile mp ON mp.user_id = at.user_id; diff --git a/backend/src/models/atemschutz.model.ts b/backend/src/models/atemschutz.model.ts new file mode 100644 index 0000000..922b40a --- /dev/null +++ b/backend/src/models/atemschutz.model.ts @@ -0,0 +1,99 @@ +// ============================================================================= +// Atemschutz (Breathing Apparatus) — Domain Model +// ============================================================================= + +import { z } from 'zod'; + +// ── Enums ─────────────────────────────────────────────────────────────────── + +export const UNTERSUCHUNG_ERGEBNIS_VALUES = ['tauglich', 'bedingt_tauglich', 'nicht_tauglich'] as const; +export type UntersuchungErgebnis = typeof UNTERSUCHUNG_ERGEBNIS_VALUES[number]; + +// ── Core DB Row ───────────────────────────────────────────────────────────── + +export interface AtemschutzTraeger { + id: string; + user_id: string; + atemschutz_lehrgang: boolean; + lehrgang_datum: Date | null; + untersuchung_datum: Date | null; + untersuchung_gueltig_bis: Date | null; + untersuchung_ergebnis: UntersuchungErgebnis | null; + leistungstest_datum: Date | null; + leistungstest_gueltig_bis: Date | null; + leistungstest_bestanden: boolean | null; + bemerkung: string | null; + created_at: Date; + updated_at: Date; +} + +// ── View Row (extended with user info + computed fields) ──────────────────── + +export interface AtemschutzUebersicht extends AtemschutzTraeger { + user_name: string | null; + user_given_name: string | null; + user_family_name: string | null; + user_email: string; + mitglied_status: string | null; + dienstgrad: string | null; + untersuchung_gueltig: boolean; + untersuchung_tage_rest: number | null; + leistungstest_gueltig: boolean; + leistungstest_tage_rest: number | null; + einsatzbereit: boolean; +} + +// ── Dashboard KPI ─────────────────────────────────────────────────────────── + +export interface AtemschutzStats { + total: number; // total tracked members + mitLehrgang: number; // have completed course + untersuchungGueltig: number; // valid medical exam + untersuchungAbgelaufen: number; // expired medical exam + untersuchungBaldFaellig: number; // expires within 90 days + leistungstestGueltig: number; // valid performance test + leistungstestAbgelaufen: number; // expired performance test + leistungstestBaldFaellig: number; // expires within 30 days + einsatzbereit: number; // fully qualified +} + +// ── Zod Validation Schemas ────────────────────────────────────────────────── + +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 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' +); + +export const CreateAtemschutzSchema = z.object({ + user_id: uuidString, + atemschutz_lehrgang: z.boolean().default(false), + lehrgang_datum: isoDate.optional(), + untersuchung_datum: isoDate.optional(), + untersuchung_gueltig_bis: isoDate.optional(), + untersuchung_ergebnis: z.enum(UNTERSUCHUNG_ERGEBNIS_VALUES).optional(), + leistungstest_datum: isoDate.optional(), + leistungstest_gueltig_bis: isoDate.optional(), + leistungstest_bestanden: z.boolean().optional(), + bemerkung: z.string().max(2000).optional(), +}); + +export type CreateAtemschutzData = z.infer; + +export const UpdateAtemschutzSchema = z.object({ + atemschutz_lehrgang: z.boolean().optional(), + lehrgang_datum: isoDate.nullable().optional(), + untersuchung_datum: isoDate.nullable().optional(), + untersuchung_gueltig_bis: isoDate.nullable().optional(), + untersuchung_ergebnis: z.enum(UNTERSUCHUNG_ERGEBNIS_VALUES).nullable().optional(), + leistungstest_datum: isoDate.nullable().optional(), + leistungstest_gueltig_bis: isoDate.nullable().optional(), + leistungstest_bestanden: z.boolean().nullable().optional(), + bemerkung: z.string().max(2000).nullable().optional(), +}); + +export type UpdateAtemschutzData = z.infer; diff --git a/backend/src/routes/atemschutz.routes.ts b/backend/src/routes/atemschutz.routes.ts new file mode 100644 index 0000000..9c1ae9e --- /dev/null +++ b/backend/src/routes/atemschutz.routes.ts @@ -0,0 +1,26 @@ +import { Router } from 'express'; +import atemschutzController from '../controllers/atemschutz.controller'; +import { authenticate } from '../middleware/auth.middleware'; +import { requireGroups } from '../middleware/rbac.middleware'; + +const ADMIN_GROUPS = ['dashboard_admin']; +const WRITE_GROUPS = ['dashboard_admin', 'dashboard_kommandant']; + +const router = Router(); + +// ── Read-only (any authenticated user) ─────────────────────────────────────── + +router.get('/', authenticate, atemschutzController.list.bind(atemschutzController)); +router.get('/stats', authenticate, atemschutzController.getStats.bind(atemschutzController)); +router.get('/:id', authenticate, atemschutzController.getOne.bind(atemschutzController)); + +// ── Write — admin + kommandant ─────────────────────────────────────────────── + +router.post('/', authenticate, requireGroups(WRITE_GROUPS), atemschutzController.create.bind(atemschutzController)); +router.patch('/:id', authenticate, requireGroups(WRITE_GROUPS), atemschutzController.update.bind(atemschutzController)); + +// ── Delete — admin only ────────────────────────────────────────────────────── + +router.delete('/:id', authenticate, requireGroups(ADMIN_GROUPS), atemschutzController.delete.bind(atemschutzController)); + +export default router; diff --git a/backend/src/services/atemschutz.service.ts b/backend/src/services/atemschutz.service.ts new file mode 100644 index 0000000..863db0a --- /dev/null +++ b/backend/src/services/atemschutz.service.ts @@ -0,0 +1,262 @@ +import pool from '../config/database'; +import logger from '../utils/logger'; +import { + AtemschutzTraeger, + AtemschutzUebersicht, + AtemschutzStats, + CreateAtemschutzData, + UpdateAtemschutzData, +} from '../models/atemschutz.model'; + +class AtemschutzService { + // ========================================================================= + // ÜBERSICHT (ALL RECORDS) + // ========================================================================= + + async getAll(): Promise { + try { + const result = await pool.query(` + SELECT * + FROM atemschutz_uebersicht + WHERE mitglied_status IN ('aktiv', 'anwärter') + ORDER BY user_family_name, user_given_name + `); + + return result.rows.map((row) => ({ + ...row, + untersuchung_tage_rest: row.untersuchung_tage_rest != null + ? parseInt(row.untersuchung_tage_rest, 10) : null, + leistungstest_tage_rest: row.leistungstest_tage_rest != null + ? parseInt(row.leistungstest_tage_rest, 10) : null, + })) as AtemschutzUebersicht[]; + } catch (error) { + logger.error('AtemschutzService.getAll fehlgeschlagen', { error }); + throw new Error('Atemschutzträger konnten nicht geladen werden'); + } + } + + // ========================================================================= + // EINZELNER DATENSATZ (BY ID) + // ========================================================================= + + async getById(id: string): Promise { + try { + const result = await pool.query( + `SELECT * FROM atemschutz_uebersicht WHERE id = $1`, + [id] + ); + + if (result.rows.length === 0) return null; + + const row = result.rows[0]; + return { + ...row, + untersuchung_tage_rest: row.untersuchung_tage_rest != null + ? parseInt(row.untersuchung_tage_rest, 10) : null, + leistungstest_tage_rest: row.leistungstest_tage_rest != null + ? parseInt(row.leistungstest_tage_rest, 10) : null, + } as AtemschutzUebersicht; + } catch (error) { + logger.error('AtemschutzService.getById fehlgeschlagen', { error, id }); + throw new Error('Atemschutzträger konnte nicht geladen werden'); + } + } + + // ========================================================================= + // EINZELNER DATENSATZ (BY USER_ID) + // ========================================================================= + + async getByUserId(userId: string): Promise { + try { + const result = await pool.query( + `SELECT * FROM atemschutz_uebersicht WHERE user_id = $1`, + [userId] + ); + + if (result.rows.length === 0) return null; + + const row = result.rows[0]; + return { + ...row, + untersuchung_tage_rest: row.untersuchung_tage_rest != null + ? parseInt(row.untersuchung_tage_rest, 10) : null, + leistungstest_tage_rest: row.leistungstest_tage_rest != null + ? parseInt(row.leistungstest_tage_rest, 10) : null, + } as AtemschutzUebersicht; + } catch (error) { + logger.error('AtemschutzService.getByUserId fehlgeschlagen', { error, userId }); + throw new Error('Atemschutzträger konnte nicht geladen werden'); + } + } + + // ========================================================================= + // CREATE + // ========================================================================= + + async create(data: CreateAtemschutzData, createdBy: string): Promise { + try { + const result = await pool.query( + `INSERT INTO atemschutz_traeger ( + id, user_id, atemschutz_lehrgang, lehrgang_datum, + untersuchung_datum, untersuchung_gueltig_bis, untersuchung_ergebnis, + leistungstest_datum, leistungstest_gueltig_bis, leistungstest_bestanden, + bemerkung + ) VALUES (uuid_generate_v4(),$1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + RETURNING *`, + [ + data.user_id, + data.atemschutz_lehrgang ?? false, + data.lehrgang_datum ?? null, + data.untersuchung_datum ?? null, + data.untersuchung_gueltig_bis ?? null, + data.untersuchung_ergebnis ?? null, + data.leistungstest_datum ?? null, + data.leistungstest_gueltig_bis ?? null, + data.leistungstest_bestanden ?? null, + data.bemerkung ?? null, + ] + ); + + const record = result.rows[0] as AtemschutzTraeger; + logger.info('Atemschutzträger erstellt', { id: record.id, userId: data.user_id, by: createdBy }); + return record; + } catch (error: any) { + if (error?.code === '23505') { + // unique constraint on user_id + throw new Error('Für diesen Benutzer existiert bereits ein Atemschutz-Eintrag'); + } + logger.error('AtemschutzService.create fehlgeschlagen', { error, createdBy }); + throw new Error('Atemschutzträger konnte nicht erstellt werden'); + } + } + + // ========================================================================= + // UPDATE + // ========================================================================= + + async update(id: string, data: UpdateAtemschutzData, updatedBy: string): Promise { + 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.atemschutz_lehrgang !== undefined) addField('atemschutz_lehrgang', data.atemschutz_lehrgang); + if (data.lehrgang_datum !== undefined) addField('lehrgang_datum', data.lehrgang_datum); + if (data.untersuchung_datum !== undefined) addField('untersuchung_datum', data.untersuchung_datum); + if (data.untersuchung_gueltig_bis !== undefined) addField('untersuchung_gueltig_bis', data.untersuchung_gueltig_bis); + if (data.untersuchung_ergebnis !== undefined) addField('untersuchung_ergebnis', data.untersuchung_ergebnis); + if (data.leistungstest_datum !== undefined) addField('leistungstest_datum', data.leistungstest_datum); + if (data.leistungstest_gueltig_bis !== undefined) addField('leistungstest_gueltig_bis', data.leistungstest_gueltig_bis); + if (data.leistungstest_bestanden !== undefined) addField('leistungstest_bestanden', data.leistungstest_bestanden); + if (data.bemerkung !== undefined) addField('bemerkung', data.bemerkung); + + if (fields.length === 0) { + throw new Error('No fields to update'); + } + + // always bump updated_at + fields.push(`updated_at = NOW()`); + + values.push(id); + const result = await pool.query( + `UPDATE atemschutz_traeger SET ${fields.join(', ')} WHERE id = $${p} RETURNING *`, + values + ); + + if (result.rows.length === 0) { + return null; + } + + const record = result.rows[0] as AtemschutzTraeger; + logger.info('Atemschutzträger aktualisiert', { id, by: updatedBy }); + return record; + } catch (error) { + logger.error('AtemschutzService.update fehlgeschlagen', { error, id, updatedBy }); + throw error; + } + } + + // ========================================================================= + // DELETE (hard delete — qualification record) + // ========================================================================= + + async delete(id: string, deletedBy: string): Promise { + try { + const result = await pool.query( + `DELETE FROM atemschutz_traeger WHERE id = $1 RETURNING id`, + [id] + ); + + if (result.rows.length === 0) { + return false; + } + + logger.info('Atemschutzträger gelöscht', { id, by: deletedBy }); + return true; + } catch (error) { + logger.error('AtemschutzService.delete fehlgeschlagen', { error, id }); + throw error; + } + } + + // ========================================================================= + // DASHBOARD KPI / STATISTIKEN + // ========================================================================= + + async getStats(): Promise { + try { + const result = await pool.query(` + SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE atemschutz_lehrgang = TRUE) AS mit_lehrgang, + COUNT(*) FILTER (WHERE untersuchung_gueltig = TRUE) AS untersuchung_gueltig, + COUNT(*) FILTER ( + WHERE untersuchung_gueltig_bis IS NOT NULL + AND untersuchung_gueltig_bis < CURRENT_DATE + ) AS untersuchung_abgelaufen, + COUNT(*) FILTER ( + WHERE untersuchung_gueltig_bis IS NOT NULL + AND untersuchung_gueltig_bis >= CURRENT_DATE + AND untersuchung_gueltig_bis <= CURRENT_DATE + INTERVAL '90 days' + ) AS untersuchung_bald_faellig, + COUNT(*) FILTER (WHERE leistungstest_gueltig = TRUE) AS leistungstest_gueltig, + COUNT(*) FILTER ( + WHERE leistungstest_gueltig_bis IS NOT NULL + AND leistungstest_gueltig_bis < CURRENT_DATE + ) AS leistungstest_abgelaufen, + COUNT(*) FILTER ( + WHERE leistungstest_gueltig_bis IS NOT NULL + AND leistungstest_gueltig_bis >= CURRENT_DATE + AND leistungstest_gueltig_bis <= CURRENT_DATE + INTERVAL '30 days' + ) AS leistungstest_bald_faellig, + COUNT(*) FILTER (WHERE einsatzbereit = TRUE) AS einsatzbereit + FROM atemschutz_uebersicht + WHERE mitglied_status IN ('aktiv', 'anwärter') + `); + + const row = result.rows[0] ?? {}; + + return { + total: parseInt(row.total ?? '0', 10), + mitLehrgang: parseInt(row.mit_lehrgang ?? '0', 10), + untersuchungGueltig: parseInt(row.untersuchung_gueltig ?? '0', 10), + untersuchungAbgelaufen: parseInt(row.untersuchung_abgelaufen ?? '0', 10), + untersuchungBaldFaellig: parseInt(row.untersuchung_bald_faellig ?? '0', 10), + leistungstestGueltig: parseInt(row.leistungstest_gueltig ?? '0', 10), + leistungstestAbgelaufen: parseInt(row.leistungstest_abgelaufen ?? '0', 10), + leistungstestBaldFaellig: parseInt(row.leistungstest_bald_faellig ?? '0', 10), + einsatzbereit: parseInt(row.einsatzbereit ?? '0', 10), + }; + } catch (error) { + logger.error('AtemschutzService.getStats fehlgeschlagen', { error }); + throw new Error('Atemschutz-Statistiken konnten nicht geladen werden'); + } + } +} + +export default new AtemschutzService(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e5f188f..247f325 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import FahrzeugForm from './pages/FahrzeugForm'; import Ausruestung from './pages/Ausruestung'; import AusruestungForm from './pages/AusruestungForm'; import AusruestungDetail from './pages/AusruestungDetail'; +import Atemschutz from './pages/Atemschutz'; import Mitglieder from './pages/Mitglieder'; import MitgliedDetail from './pages/MitgliedDetail'; import Kalender from './pages/Kalender'; @@ -135,6 +136,14 @@ function App() { } /> + + + + } + /> = ({ + hideWhenEmpty = false, +}) => { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + const fetchStats = async () => { + try { + setLoading(true); + setError(null); + const data = await atemschutzApi.getStats(); + if (mounted) setStats(data); + } catch { + if (mounted) setError('Atemschutzstatus konnte nicht geladen werden.'); + } finally { + if (mounted) setLoading(false); + } + }; + fetchStats(); + return () => { + mounted = false; + }; + }, []); + + if (loading) { + return ( + + + + + Atemschutzstatus wird geladen... + + + + ); + } + + if (error) { + return ( + + + + {error} + + + + ); + } + + if (!stats) return null; + + // Determine if there are any concerns + const hasConcerns = + stats.untersuchungAbgelaufen > 0 || + stats.leistungstestAbgelaufen > 0 || + stats.untersuchungBaldFaellig > 0 || + stats.leistungstestBaldFaellig > 0; + + const allGood = stats.einsatzbereit === stats.total && !hasConcerns; + + // If hideWhenEmpty and everything is fine, render nothing + if (hideWhenEmpty && allGood) return null; + + return ( + + + + Atemschutz + + + {/* Main metric */} + + {stats.einsatzbereit}/{stats.total} + + + einsatzbereit + + + {/* Concerns list */} + {hasConcerns && ( + + {stats.untersuchungAbgelaufen > 0 && ( + + {stats.untersuchungAbgelaufen} Untersuchung{stats.untersuchungAbgelaufen !== 1 ? 'en' : ''} abgelaufen + + )} + {stats.leistungstestAbgelaufen > 0 && ( + + {stats.leistungstestAbgelaufen} Leistungstest{stats.leistungstestAbgelaufen !== 1 ? 's' : ''} abgelaufen + + )} + {stats.untersuchungBaldFaellig > 0 && ( + + {stats.untersuchungBaldFaellig} Untersuchung{stats.untersuchungBaldFaellig !== 1 ? 'en' : ''} bald fällig + + )} + {stats.leistungstestBaldFaellig > 0 && ( + + {stats.leistungstestBaldFaellig} Leistungstest{stats.leistungstestBaldFaellig !== 1 ? 's' : ''} bald fällig + + )} + + )} + + {/* All good message */} + {allGood && ( + + Alle Atemschutzträger einsatzbereit + + )} + + {/* Link to management page */} + + + Zur Verwaltung + + + + + ); +}; + +export default AtemschutzDashboardCard; diff --git a/frontend/src/components/equipment/EquipmentAlerts.tsx b/frontend/src/components/equipment/EquipmentAlerts.tsx new file mode 100644 index 0000000..9259dc4 --- /dev/null +++ b/frontend/src/components/equipment/EquipmentAlerts.tsx @@ -0,0 +1,251 @@ +import React, { useEffect, useState } from 'react'; +import { + Alert, + AlertTitle, + Box, + CircularProgress, + Link, + Typography, +} from '@mui/material'; +import { Link as RouterLink } from 'react-router-dom'; +import { equipmentApi } from '../../services/equipment'; +import { + AusruestungStatusLabel, + AusruestungStatus, +} from '../../types/equipment.types'; +import type { + EquipmentStats, + AusruestungListItem, +} from '../../types/equipment.types'; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); +} + +interface EquipmentAlertsProps { + daysAhead?: number; + hideWhenEmpty?: boolean; +} + +interface AlertGroup { + key: string; + severity: 'error' | 'warning'; + title: string; + content: React.ReactNode; +} + +const EquipmentAlerts: React.FC = ({ + daysAhead = 30, + hideWhenEmpty = true, +}) => { + const [stats, setStats] = useState(null); + const [alerts, setAlerts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + const fetchData = async () => { + try { + setLoading(true); + setError(null); + const [statsData, alertsData] = await Promise.all([ + equipmentApi.getStats(), + equipmentApi.getAlerts(daysAhead), + ]); + if (mounted) { + setStats(statsData); + setAlerts(alertsData); + } + } catch { + if (mounted) setError('Ausrüstungshinweise konnten nicht geladen werden.'); + } finally { + if (mounted) setLoading(false); + } + }; + fetchData(); + return () => { mounted = false; }; + }, [daysAhead]); + + if (loading) { + return ( + + + + Ausrüstungsstatus wird geprüft... + + + ); + } + + if (error) { + return {error}; + } + + if (!stats) return null; + + // Separate alerts into overdue vs. upcoming inspections + const overdueItems = alerts.filter( + (a) => a.pruefung_tage_bis_faelligkeit !== null && a.pruefung_tage_bis_faelligkeit < 0 + ); + const upcomingItems = alerts.filter( + (a) => a.pruefung_tage_bis_faelligkeit !== null && a.pruefung_tage_bis_faelligkeit >= 0 + ); + + // Build alert groups based on stats + const groups: AlertGroup[] = []; + + // 1. Overdue inspections + if (stats.inspectionsOverdue > 0 && overdueItems.length > 0) { + groups.push({ + key: 'overdue', + severity: 'error', + title: `Überfällige Prüfungen (${stats.inspectionsOverdue})`, + content: ( + + {overdueItems.map((item) => { + const tage = Math.abs(item.pruefung_tage_bis_faelligkeit!); + const tageText = `seit ${tage} Tag${tage === 1 ? '' : 'en'} überfällig`; + return ( + + + {item.bezeichnung} + + {item.kategorie_kurzname ? ` (${item.kategorie_kurzname})` : ''} + {' — '} + + {tageText} + {item.naechste_pruefung_am + ? ` (${formatDate(item.naechste_pruefung_am)})` + : ''} + + + ); + })} + + ), + }); + } + + // 2. Important equipment not ready (affects vehicle readiness) + if (stats.wichtigNichtBereit > 0) { + groups.push({ + key: 'wichtig', + severity: 'error', + title: `Wichtige Ausrüstung nicht einsatzbereit (${stats.wichtigNichtBereit})`, + content: ( + + {stats.wichtigNichtBereit} wichtige{stats.wichtigNichtBereit === 1 ? 's' : ''} + {' '}Ausrüstungsteil{stats.wichtigNichtBereit === 1 ? '' : 'e'} nicht + einsatzbereit — Fahrzeugbereitschaft kann beeinträchtigt sein. + + ), + }); + } + + // 3. Damaged equipment + if (stats.beschaedigt > 0) { + groups.push({ + key: 'beschaedigt', + severity: 'error', + title: `${AusruestungStatusLabel[AusruestungStatus.Beschaedigt]} (${stats.beschaedigt})`, + content: ( + + {stats.beschaedigt} Ausrüstungsteil{stats.beschaedigt === 1 ? '' : 'e'}{' '} + {stats.beschaedigt === 1 ? 'ist' : 'sind'} als{' '} + {AusruestungStatusLabel[AusruestungStatus.Beschaedigt].toLowerCase()} gemeldet. + + ), + }); + } + + // 4. Upcoming inspections + if (stats.inspectionsDue > 0 && upcomingItems.length > 0) { + groups.push({ + key: 'upcoming', + severity: 'warning', + title: `Prüfungen fällig in den nächsten ${daysAhead} Tagen (${stats.inspectionsDue})`, + content: ( + + {upcomingItems.map((item) => { + const tage = item.pruefung_tage_bis_faelligkeit!; + const tageText = + tage === 0 + ? 'heute fällig' + : `fällig in ${tage} Tag${tage === 1 ? '' : 'en'}`; + return ( + + + {item.bezeichnung} + + {item.kategorie_kurzname ? ` (${item.kategorie_kurzname})` : ''} + {' — '} + + {tageText} + {item.naechste_pruefung_am + ? ` (${formatDate(item.naechste_pruefung_am)})` + : ''} + + + ); + })} + + ), + }); + } + + // 5. In maintenance + if (stats.inWartung > 0) { + groups.push({ + key: 'wartung', + severity: 'warning', + title: `${AusruestungStatusLabel[AusruestungStatus.InWartung]} (${stats.inWartung})`, + content: ( + + {stats.inWartung} Ausrüstungsteil{stats.inWartung === 1 ? '' : 'e'}{' '} + {stats.inWartung === 1 ? 'befindet' : 'befinden'} sich derzeit in Wartung. + + ), + }); + } + + // Nothing to show + if (groups.length === 0) { + if (hideWhenEmpty) return null; + return ( + + Alle Ausrüstung ist einsatzbereit. Keine Auffälligkeiten in den nächsten{' '} + {daysAhead} Tagen. + + ); + } + + return ( + + {groups.map(({ key, severity, title, content }) => ( + + {title} + {content} + + ))} + + ); +}; + +export default EquipmentAlerts; diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx index 959b1fb..acafd69 100644 --- a/frontend/src/components/shared/Sidebar.tsx +++ b/frontend/src/components/shared/Sidebar.tsx @@ -10,11 +10,10 @@ import { } from '@mui/material'; import { Dashboard as DashboardIcon, - LocalFireDepartment, DirectionsCar, Build, People, - CalendarMonth as CalendarIcon, + Air, } from '@mui/icons-material'; import { useNavigate, useLocation } from 'react-router-dom'; @@ -32,11 +31,6 @@ const navigationItems: NavigationItem[] = [ icon: , path: '/dashboard', }, - { - text: 'Einsätze', - icon: , - path: '/einsaetze', - }, { text: 'Fahrzeuge', icon: , @@ -53,9 +47,9 @@ const navigationItems: NavigationItem[] = [ path: '/mitglieder', }, { - text: 'Dienstkalender', - icon: , - path: '/kalender', + text: 'Atemschutz', + icon: , + path: '/atemschutz', }, ]; diff --git a/frontend/src/pages/Atemschutz.tsx b/frontend/src/pages/Atemschutz.tsx new file mode 100644 index 0000000..520d14f --- /dev/null +++ b/frontend/src/pages/Atemschutz.tsx @@ -0,0 +1,777 @@ +import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Checkbox, + Chip, + CircularProgress, + Container, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Fab, + FormControl, + FormControlLabel, + Grid, + InputAdornment, + InputLabel, + MenuItem, + Select, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { + Add, + Check, + Close, + Edit, + Delete, + Search, +} from '@mui/icons-material'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { atemschutzApi } from '../services/atemschutz'; +import { membersService } from '../services/members'; +import { useNotification } from '../contexts/NotificationContext'; +import type { + AtemschutzUebersicht, + AtemschutzStats, + CreateAtemschutzPayload, + UpdateAtemschutzPayload, + UntersuchungErgebnis, +} from '../types/atemschutz.types'; +import { UntersuchungErgebnisLabel } from '../types/atemschutz.types'; +import type { MemberListItem } from '../types/member.types'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function formatDate(iso: string | null): string { + if (!iso) return '—'; + return new Date(iso).toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); +} + +function getDisplayName(item: AtemschutzUebersicht): string { + if (item.user_family_name || item.user_given_name) { + return [item.user_family_name, item.user_given_name].filter(Boolean).join(', '); + } + return item.user_name || item.user_email; +} + +type ValidityColor = 'success.main' | 'error.main' | 'warning.main' | 'text.secondary'; + +function getValidityColor( + gueltigBis: string | null, + tageRest: number | null, + soonThresholdDays: number +): ValidityColor { + if (!gueltigBis || tageRest === null) return 'text.secondary'; + if (tageRest < 0) return 'error.main'; + if (tageRest <= soonThresholdDays) return 'warning.main'; + return 'success.main'; +} + +// ── Initial form state ─────────────────────────────────────────────────────── + +interface AtemschutzFormState { + user_id: string; + atemschutz_lehrgang: boolean; + lehrgang_datum: string; + untersuchung_datum: string; + untersuchung_gueltig_bis: string; + untersuchung_ergebnis: UntersuchungErgebnis | ''; + leistungstest_datum: string; + leistungstest_gueltig_bis: string; + leistungstest_bestanden: boolean; + bemerkung: string; +} + +const EMPTY_FORM: AtemschutzFormState = { + user_id: '', + atemschutz_lehrgang: false, + lehrgang_datum: '', + untersuchung_datum: '', + untersuchung_gueltig_bis: '', + untersuchung_ergebnis: '', + leistungstest_datum: '', + leistungstest_gueltig_bis: '', + leistungstest_bestanden: false, + bemerkung: '', +}; + +// ── Stats Card ─────────────────────────────────────────────────────────────── + +interface StatCardProps { + label: string; + value: number; + color?: string; + bgcolor?: string; +} + +const StatCard: React.FC = ({ label, value, color, bgcolor }) => ( + + + + {value} + + + {label} + + + +); + +// ── Main Page ──────────────────────────────────────────────────────────────── + +function Atemschutz() { + const notification = useNotification(); + + // Data state + const [traeger, setTraeger] = useState([]); + const [stats, setStats] = useState(null); + const [members, setMembers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Filter state + const [search, setSearch] = useState(''); + + // Dialog state + const [dialogOpen, setDialogOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState({ ...EMPTY_FORM }); + const [dialogLoading, setDialogLoading] = useState(false); + const [dialogError, setDialogError] = useState(null); + + // Delete confirmation + const [deleteId, setDeleteId] = useState(null); + const [deleteLoading, setDeleteLoading] = useState(false); + + // ── Data loading ───────────────────────────────────────────────────────── + + const fetchData = useCallback(async () => { + try { + setLoading(true); + setError(null); + const [traegerData, statsData, membersData] = await Promise.all([ + atemschutzApi.getAll(), + atemschutzApi.getStats(), + membersService.getMembers({ pageSize: 500 }), + ]); + setTraeger(traegerData); + setStats(statsData); + setMembers(membersData.items); + } catch { + setError('Atemschutzdaten konnten nicht geladen werden. Bitte versuchen Sie es erneut.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // ── Filtering ──────────────────────────────────────────────────────────── + + const filtered = useMemo(() => { + if (!search.trim()) return traeger; + const q = search.toLowerCase(); + return traeger.filter((item) => { + const name = getDisplayName(item).toLowerCase(); + const email = item.user_email.toLowerCase(); + const dienstgrad = (item.dienstgrad || '').toLowerCase(); + return name.includes(q) || email.includes(q) || dienstgrad.includes(q); + }); + }, [traeger, search]); + + // Members who do not already have an Atemschutz record + const availableMembers = useMemo(() => { + const existingUserIds = new Set(traeger.map((t) => t.user_id)); + return members.filter((m) => !existingUserIds.has(m.id)); + }, [members, traeger]); + + // ── Dialog handlers ────────────────────────────────────────────────────── + + const handleOpenCreate = () => { + setEditingId(null); + setForm({ ...EMPTY_FORM }); + setDialogError(null); + setDialogOpen(true); + }; + + const handleOpenEdit = (item: AtemschutzUebersicht) => { + setEditingId(item.id); + setForm({ + user_id: item.user_id, + atemschutz_lehrgang: item.atemschutz_lehrgang, + lehrgang_datum: item.lehrgang_datum || '', + untersuchung_datum: item.untersuchung_datum || '', + untersuchung_gueltig_bis: item.untersuchung_gueltig_bis || '', + untersuchung_ergebnis: item.untersuchung_ergebnis || '', + leistungstest_datum: item.leistungstest_datum || '', + leistungstest_gueltig_bis: item.leistungstest_gueltig_bis || '', + leistungstest_bestanden: item.leistungstest_bestanden || false, + bemerkung: item.bemerkung || '', + }); + setDialogError(null); + setDialogOpen(true); + }; + + const handleDialogClose = () => { + setDialogOpen(false); + setEditingId(null); + setForm({ ...EMPTY_FORM }); + setDialogError(null); + }; + + const handleFormChange = ( + field: keyof AtemschutzFormState, + value: string | boolean + ) => { + setForm((prev) => ({ ...prev, [field]: value })); + }; + + const handleSubmit = async () => { + setDialogError(null); + + if (!editingId && !form.user_id) { + setDialogError('Bitte ein Mitglied auswählen.'); + return; + } + + setDialogLoading(true); + try { + if (editingId) { + const payload: UpdateAtemschutzPayload = { + atemschutz_lehrgang: form.atemschutz_lehrgang, + lehrgang_datum: form.lehrgang_datum || undefined, + untersuchung_datum: form.untersuchung_datum || undefined, + untersuchung_gueltig_bis: form.untersuchung_gueltig_bis || undefined, + untersuchung_ergebnis: (form.untersuchung_ergebnis as UntersuchungErgebnis) || undefined, + leistungstest_datum: form.leistungstest_datum || undefined, + leistungstest_gueltig_bis: form.leistungstest_gueltig_bis || undefined, + leistungstest_bestanden: form.leistungstest_bestanden, + bemerkung: form.bemerkung || undefined, + }; + await atemschutzApi.update(editingId, payload); + notification.showSuccess('Atemschutzträger erfolgreich aktualisiert.'); + } else { + const payload: CreateAtemschutzPayload = { + user_id: form.user_id, + atemschutz_lehrgang: form.atemschutz_lehrgang, + lehrgang_datum: form.lehrgang_datum || undefined, + untersuchung_datum: form.untersuchung_datum || undefined, + untersuchung_gueltig_bis: form.untersuchung_gueltig_bis || undefined, + untersuchung_ergebnis: (form.untersuchung_ergebnis as UntersuchungErgebnis) || undefined, + leistungstest_datum: form.leistungstest_datum || undefined, + leistungstest_gueltig_bis: form.leistungstest_gueltig_bis || undefined, + leistungstest_bestanden: form.leistungstest_bestanden, + bemerkung: form.bemerkung || undefined, + }; + await atemschutzApi.create(payload); + notification.showSuccess('Atemschutzträger erfolgreich angelegt.'); + } + handleDialogClose(); + fetchData(); + } catch (err: any) { + const msg = err?.message || 'Ein Fehler ist aufgetreten.'; + setDialogError(msg); + notification.showError(msg); + } finally { + setDialogLoading(false); + } + }; + + // ── Delete handlers ────────────────────────────────────────────────────── + + const handleDeleteConfirm = async () => { + if (!deleteId) return; + setDeleteLoading(true); + try { + await atemschutzApi.delete(deleteId); + notification.showSuccess('Atemschutzträger erfolgreich gelöscht.'); + setDeleteId(null); + fetchData(); + } catch (err: any) { + notification.showError(err?.message || 'Löschen fehlgeschlagen.'); + } finally { + setDeleteLoading(false); + } + }; + + // ── Render ─────────────────────────────────────────────────────────────── + + return ( + + + {/* Header */} + + + + Atemschutzverwaltung + + {!loading && stats && ( + + + {stats.total} Gesamt + + {'·'} + + {stats.einsatzbereit} Einsatzbereit + + {'·'} + 0 ? 'error.main' : 'text.secondary'} + fontWeight={stats.untersuchungAbgelaufen > 0 ? 600 : 400} + > + {stats.untersuchungAbgelaufen} Untersuchung abgelaufen + + + )} + + + + {/* Stats cards */} + {!loading && stats && ( + + + + + + + + + + + + + + + )} + + {/* Search bar */} + + setSearch(e.target.value)} + size="small" + sx={{ minWidth: 280, maxWidth: 480, width: '100%' }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + {/* Loading state */} + {loading && ( + + + + )} + + {/* Error state */} + {!loading && error && ( + + Erneut versuchen + + } + > + {error} + + )} + + {/* Empty state */} + {!loading && !error && filtered.length === 0 && ( + + + {traeger.length === 0 + ? 'Keine Atemschutzträger vorhanden' + : 'Keine Ergebnisse gefunden'} + + + )} + + {/* Table */} + {!loading && !error && filtered.length > 0 && ( + + + + + Name + Dienstgrad + Lehrgang + Untersuchung gültig bis + Leistungstest gültig bis + Status + Aktionen + + + + {filtered.map((item) => { + const untersuchungColor = getValidityColor( + item.untersuchung_gueltig_bis, + item.untersuchung_tage_rest, + 90 + ); + const leistungstestColor = getValidityColor( + item.leistungstest_gueltig_bis, + item.leistungstest_tage_rest, + 30 + ); + + return ( + + + + {getDisplayName(item)} + + + {item.user_email} + + + + + {item.dienstgrad || '—'} + + + + {item.atemschutz_lehrgang ? ( + + + + ) : ( + + )} + + + + + {formatDate(item.untersuchung_gueltig_bis)} + + + + + + + {formatDate(item.leistungstest_gueltig_bis)} + + + + + + + + + + + + + + + + ); + })} + +
+
+ )} + + {/* FAB to create */} + + + + + {/* ── Add / Edit Dialog ───────────────────────────────────────────── */} + + + {editingId ? 'Atemschutzträger bearbeiten' : 'Neuen Atemschutzträger anlegen'} + + + {dialogError && ( + + {dialogError} + + )} + + + {/* User selection (only when creating) */} + {!editingId && ( + + + Mitglied + + + + )} + + {/* Lehrgang */} + + + Lehrgang + + + + handleFormChange('atemschutz_lehrgang', e.target.checked)} + /> + } + label="Lehrgang absolviert" + /> + + + handleFormChange('lehrgang_datum', e.target.value)} + InputLabelProps={{ shrink: true }} + /> + + + {/* Untersuchung */} + + + Untersuchung + + + + handleFormChange('untersuchung_datum', e.target.value)} + InputLabelProps={{ shrink: true }} + /> + + + handleFormChange('untersuchung_gueltig_bis', e.target.value)} + InputLabelProps={{ shrink: true }} + /> + + + + Ergebnis + + + + + {/* Leistungstest */} + + + Leistungstest + + + + handleFormChange('leistungstest_datum', e.target.value)} + InputLabelProps={{ shrink: true }} + /> + + + handleFormChange('leistungstest_gueltig_bis', e.target.value)} + InputLabelProps={{ shrink: true }} + /> + + + handleFormChange('leistungstest_bestanden', e.target.checked)} + /> + } + label="Leistungstest bestanden" + /> + + + {/* Bemerkung */} + + handleFormChange('bemerkung', e.target.value)} + /> + + + + + + + + + + {/* ── Delete Confirmation Dialog ──────────────────────────────────── */} + setDeleteId(null)}> + Atemschutzträger löschen + + + Soll dieser Atemschutzträger-Eintrag wirklich gelöscht werden? Diese Aktion kann + nicht rückgängig gemacht werden. + + + + + + + +
+
+ ); +} + +export default Atemschutz; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index a2a59dd..5a6c560 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -7,9 +7,6 @@ import { Fade, } from '@mui/material'; import { - People, - Warning, - EventNote, DirectionsCar, } from '@mui/icons-material'; import { useAuth } from '../contexts/AuthContext'; @@ -23,13 +20,18 @@ import NextcloudTalkWidget from '../components/dashboard/NextcloudTalkWidget'; import StatsCard from '../components/dashboard/StatsCard'; import ActivityFeed from '../components/dashboard/ActivityFeed'; import InspectionAlerts from '../components/vehicles/InspectionAlerts'; +import EquipmentAlerts from '../components/equipment/EquipmentAlerts'; +import AtemschutzDashboardCard from '../components/atemschutz/AtemschutzDashboardCard'; import { vehiclesApi } from '../services/vehicles'; +import { equipmentApi } from '../services/equipment'; import type { VehicleStats } from '../types/vehicle.types'; +import type { VehicleEquipmentWarning } from '../types/equipment.types'; function Dashboard() { const { user } = useAuth(); const [dataLoading, setDataLoading] = useState(true); const [vehicleStats, setVehicleStats] = useState(null); + const [vehicleWarnings, setVehicleWarnings] = useState([]); useEffect(() => { const timer = setTimeout(() => { @@ -43,6 +45,13 @@ function Dashboard() { // Non-critical — KPI will fall back to placeholder }); + // Fetch vehicle equipment warnings + equipmentApi.getVehicleWarnings() + .then((w) => setVehicleWarnings(w)) + .catch(() => { + // Non-critical — warning indicator simply won't appear + }); + return () => clearTimeout(timer); }, []); @@ -80,62 +89,12 @@ function Dashboard() { )} - {/* Stats Cards Row */} - - {dataLoading ? ( - - ) : ( - - - - - - )} - - - {dataLoading ? ( - - ) : ( - - - - - - )} - - - {dataLoading ? ( - - ) : ( - - - - - - )} - - {/* Live vehicle KPI — einsatzbereit count from API */} {dataLoading ? ( ) : ( - + + {vehicleWarnings.length > 0 && ( + + {new Set(vehicleWarnings.map(w => w.fahrzeug_id)).size} Fzg. mit Ausrüstungsmangel + + )} )} @@ -161,6 +125,24 @@ function Dashboard() { + {/* Equipment Alerts Panel */} + + + + + + + + + {/* Atemschutz Status Card */} + + + + + + + + {/* Service Integration Cards */} diff --git a/frontend/src/services/atemschutz.ts b/frontend/src/services/atemschutz.ts new file mode 100644 index 0000000..b25c6c9 --- /dev/null +++ b/frontend/src/services/atemschutz.ts @@ -0,0 +1,64 @@ +import { api } from './api'; +import type { + AtemschutzUebersicht, + AtemschutzTraeger, + AtemschutzStats, + CreateAtemschutzPayload, + UpdateAtemschutzPayload, +} from '../types/atemschutz.types'; + +async function unwrap( + promise: ReturnType> +): Promise { + 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 atemschutzApi = { + async getAll(): Promise { + return unwrap( + api.get<{ success: boolean; data: AtemschutzUebersicht[] }>('/api/atemschutz') + ); + }, + + async getById(id: string): Promise { + return unwrap( + api.get<{ success: boolean; data: AtemschutzUebersicht }>(`/api/atemschutz/${id}`) + ); + }, + + async getStats(): Promise { + return unwrap( + api.get<{ success: boolean; data: AtemschutzStats }>('/api/atemschutz/stats') + ); + }, + + async create(payload: CreateAtemschutzPayload): Promise { + const response = await api.post<{ success: boolean; data: AtemschutzTraeger }>( + '/api/atemschutz', + 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: UpdateAtemschutzPayload): Promise { + const response = await api.patch<{ success: boolean; data: AtemschutzTraeger }>( + `/api/atemschutz/${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 { + await api.delete(`/api/atemschutz/${id}`); + }, +}; diff --git a/frontend/src/types/atemschutz.types.ts b/frontend/src/types/atemschutz.types.ts new file mode 100644 index 0000000..2c82b8c --- /dev/null +++ b/frontend/src/types/atemschutz.types.ts @@ -0,0 +1,76 @@ +// ============================================================================= +// Atemschutz (Breathing Apparatus) Carrier Management — Frontend Type Definitions +// ============================================================================= + +export type UntersuchungErgebnis = 'tauglich' | 'bedingt_tauglich' | 'nicht_tauglich'; + +export const UntersuchungErgebnisLabel: Record = { + tauglich: 'Tauglich', + bedingt_tauglich: 'Bedingt tauglich', + nicht_tauglich: 'Nicht tauglich', +}; + +// ── Core Entity ──────────────────────────────────────────────────────────── + +export interface AtemschutzTraeger { + id: string; + user_id: string; + atemschutz_lehrgang: boolean; + lehrgang_datum: string | null; + untersuchung_datum: string | null; + untersuchung_gueltig_bis: string | null; + untersuchung_ergebnis: UntersuchungErgebnis | null; + leistungstest_datum: string | null; + leistungstest_gueltig_bis: string | null; + leistungstest_bestanden: boolean | null; + bemerkung: string | null; + created_at: string; + updated_at: string; +} + +// ── List / Overview Shape (joined with user data) ────────────────────────── + +export interface AtemschutzUebersicht extends AtemschutzTraeger { + user_name: string | null; + user_given_name: string | null; + user_family_name: string | null; + user_email: string; + mitglied_status: string | null; + dienstgrad: string | null; + untersuchung_gueltig: boolean; + untersuchung_tage_rest: number | null; + leistungstest_gueltig: boolean; + leistungstest_tage_rest: number | null; + einsatzbereit: boolean; +} + +// ── Dashboard KPI ────────────────────────────────────────────────────────── + +export interface AtemschutzStats { + total: number; + mitLehrgang: number; + untersuchungGueltig: number; + untersuchungAbgelaufen: number; + untersuchungBaldFaellig: number; + leistungstestGueltig: number; + leistungstestAbgelaufen: number; + leistungstestBaldFaellig: number; + einsatzbereit: number; +} + +// ── Request Payload Types ────────────────────────────────────────────────── + +export interface CreateAtemschutzPayload { + user_id: string; + atemschutz_lehrgang?: boolean; + lehrgang_datum?: string; + untersuchung_datum?: string; + untersuchung_gueltig_bis?: string; + untersuchung_ergebnis?: UntersuchungErgebnis; + leistungstest_datum?: string; + leistungstest_gueltig_bis?: string; + leistungstest_bestanden?: boolean; + bemerkung?: string; +} + +export type UpdateAtemschutzPayload = Omit, 'user_id'>;