add now features
This commit is contained in:
@@ -20,26 +20,28 @@ app.use(cors({
|
|||||||
credentials: true,
|
credentials: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Rate limiting - general API routes
|
// Rate limiting - general API routes (applied below, after auth limiter)
|
||||||
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 - 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({
|
const authLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
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.',
|
message: 'Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.',
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use('/api/auth', authLimiter);
|
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
|
// Body parsing middleware
|
||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(express.json({ limit: '10mb' }));
|
||||||
@@ -75,6 +77,7 @@ import vehicleRoutes from './routes/vehicle.routes';
|
|||||||
import incidentRoutes from './routes/incident.routes';
|
import incidentRoutes from './routes/incident.routes';
|
||||||
import equipmentRoutes from './routes/equipment.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/auth', authRoutes);
|
||||||
app.use('/api/user', userRoutes);
|
app.use('/api/user', userRoutes);
|
||||||
@@ -84,6 +87,7 @@ 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);
|
app.use('/api/equipment', equipmentRoutes);
|
||||||
|
app.use('/api/atemschutz', atemschutzRoutes);
|
||||||
app.use('/api/nextcloud/talk', nextcloudRoutes);
|
app.use('/api/nextcloud/talk', nextcloudRoutes);
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
|
|||||||
139
backend/src/controllers/atemschutz.controller.ts
Normal file
139
backend/src/controllers/atemschutz.controller.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
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();
|
||||||
129
backend/src/database/migrations/014_create_atemschutz.sql
Normal file
129
backend/src/database/migrations/014_create_atemschutz.sql
Normal file
@@ -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;
|
||||||
99
backend/src/models/atemschutz.model.ts
Normal file
99
backend/src/models/atemschutz.model.ts
Normal file
@@ -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<typeof CreateAtemschutzSchema>;
|
||||||
|
|
||||||
|
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<typeof UpdateAtemschutzSchema>;
|
||||||
26
backend/src/routes/atemschutz.routes.ts
Normal file
26
backend/src/routes/atemschutz.routes.ts
Normal file
@@ -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;
|
||||||
262
backend/src/services/atemschutz.service.ts
Normal file
262
backend/src/services/atemschutz.service.ts
Normal file
@@ -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<AtemschutzUebersicht[]> {
|
||||||
|
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<AtemschutzUebersicht | null> {
|
||||||
|
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<AtemschutzUebersicht | null> {
|
||||||
|
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<AtemschutzTraeger> {
|
||||||
|
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<AtemschutzTraeger | 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.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<boolean> {
|
||||||
|
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<AtemschutzStats> {
|
||||||
|
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();
|
||||||
@@ -16,6 +16,7 @@ import FahrzeugForm from './pages/FahrzeugForm';
|
|||||||
import Ausruestung from './pages/Ausruestung';
|
import Ausruestung from './pages/Ausruestung';
|
||||||
import AusruestungForm from './pages/AusruestungForm';
|
import AusruestungForm from './pages/AusruestungForm';
|
||||||
import AusruestungDetail from './pages/AusruestungDetail';
|
import AusruestungDetail from './pages/AusruestungDetail';
|
||||||
|
import Atemschutz from './pages/Atemschutz';
|
||||||
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';
|
||||||
@@ -135,6 +136,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/atemschutz"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Atemschutz />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/mitglieder"
|
path="/mitglieder"
|
||||||
element={
|
element={
|
||||||
|
|||||||
148
frontend/src/components/atemschutz/AtemschutzDashboardCard.tsx
Normal file
148
frontend/src/components/atemschutz/AtemschutzDashboardCard.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CircularProgress,
|
||||||
|
Link,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
import { atemschutzApi } from '../../services/atemschutz';
|
||||||
|
import type { AtemschutzStats } from '../../types/atemschutz.types';
|
||||||
|
|
||||||
|
interface AtemschutzDashboardCardProps {
|
||||||
|
hideWhenEmpty?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AtemschutzDashboardCard: React.FC<AtemschutzDashboardCardProps> = ({
|
||||||
|
hideWhenEmpty = false,
|
||||||
|
}) => {
|
||||||
|
const [stats, setStats] = useState<AtemschutzStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 2 }}>
|
||||||
|
<CircularProgress size={16} />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Atemschutzstatus wird geladen...
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="body2" color="error">
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Atemschutz
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Main metric */}
|
||||||
|
<Typography variant="h4" fontWeight={700} color={allGood ? 'success.main' : 'text.primary'}>
|
||||||
|
{stats.einsatzbereit}/{stats.total}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||||
|
einsatzbereit
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Concerns list */}
|
||||||
|
{hasConcerns && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
{stats.untersuchungAbgelaufen > 0 && (
|
||||||
|
<Typography variant="body2" color="error.main">
|
||||||
|
{stats.untersuchungAbgelaufen} Untersuchung{stats.untersuchungAbgelaufen !== 1 ? 'en' : ''} abgelaufen
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{stats.leistungstestAbgelaufen > 0 && (
|
||||||
|
<Typography variant="body2" color="error.main">
|
||||||
|
{stats.leistungstestAbgelaufen} Leistungstest{stats.leistungstestAbgelaufen !== 1 ? 's' : ''} abgelaufen
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{stats.untersuchungBaldFaellig > 0 && (
|
||||||
|
<Typography variant="body2" color="warning.main">
|
||||||
|
{stats.untersuchungBaldFaellig} Untersuchung{stats.untersuchungBaldFaellig !== 1 ? 'en' : ''} bald fällig
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{stats.leistungstestBaldFaellig > 0 && (
|
||||||
|
<Typography variant="body2" color="warning.main">
|
||||||
|
{stats.leistungstestBaldFaellig} Leistungstest{stats.leistungstestBaldFaellig !== 1 ? 's' : ''} bald fällig
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* All good message */}
|
||||||
|
{allGood && (
|
||||||
|
<Typography variant="body2" color="success.main">
|
||||||
|
Alle Atemschutzträger einsatzbereit
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Link to management page */}
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Link
|
||||||
|
component={RouterLink}
|
||||||
|
to="/atemschutz"
|
||||||
|
underline="hover"
|
||||||
|
variant="body2"
|
||||||
|
>
|
||||||
|
Zur Verwaltung
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AtemschutzDashboardCard;
|
||||||
251
frontend/src/components/equipment/EquipmentAlerts.tsx
Normal file
251
frontend/src/components/equipment/EquipmentAlerts.tsx
Normal file
@@ -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<EquipmentAlertsProps> = ({
|
||||||
|
daysAhead = 30,
|
||||||
|
hideWhenEmpty = true,
|
||||||
|
}) => {
|
||||||
|
const [stats, setStats] = useState<EquipmentStats | null>(null);
|
||||||
|
const [alerts, setAlerts] = useState<AusruestungListItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1 }}>
|
||||||
|
<CircularProgress size={16} />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Ausrüstungsstatus wird geprüft...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <Alert severity="error">{error}</Alert>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: (
|
||||||
|
<Box component="ul" sx={{ m: 0, pl: 2 }}>
|
||||||
|
{overdueItems.map((item) => {
|
||||||
|
const tage = Math.abs(item.pruefung_tage_bis_faelligkeit!);
|
||||||
|
const tageText = `seit ${tage} Tag${tage === 1 ? '' : 'en'} überfällig`;
|
||||||
|
return (
|
||||||
|
<Box key={item.id} component="li" sx={{ mb: 0.5 }}>
|
||||||
|
<Link
|
||||||
|
component={RouterLink}
|
||||||
|
to={`/ausruestung/${item.id}`}
|
||||||
|
color="inherit"
|
||||||
|
underline="hover"
|
||||||
|
sx={{ fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
{item.bezeichnung}
|
||||||
|
</Link>
|
||||||
|
{item.kategorie_kurzname ? ` (${item.kategorie_kurzname})` : ''}
|
||||||
|
{' — '}
|
||||||
|
<Typography component="span" variant="body2">
|
||||||
|
{tageText}
|
||||||
|
{item.naechste_pruefung_am
|
||||||
|
? ` (${formatDate(item.naechste_pruefung_am)})`
|
||||||
|
: ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: (
|
||||||
|
<Typography variant="body2">
|
||||||
|
{stats.wichtigNichtBereit} wichtige{stats.wichtigNichtBereit === 1 ? 's' : ''}
|
||||||
|
{' '}Ausrüstungsteil{stats.wichtigNichtBereit === 1 ? '' : 'e'} nicht
|
||||||
|
einsatzbereit — Fahrzeugbereitschaft kann beeinträchtigt sein.
|
||||||
|
</Typography>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Damaged equipment
|
||||||
|
if (stats.beschaedigt > 0) {
|
||||||
|
groups.push({
|
||||||
|
key: 'beschaedigt',
|
||||||
|
severity: 'error',
|
||||||
|
title: `${AusruestungStatusLabel[AusruestungStatus.Beschaedigt]} (${stats.beschaedigt})`,
|
||||||
|
content: (
|
||||||
|
<Typography variant="body2">
|
||||||
|
{stats.beschaedigt} Ausrüstungsteil{stats.beschaedigt === 1 ? '' : 'e'}{' '}
|
||||||
|
{stats.beschaedigt === 1 ? 'ist' : 'sind'} als{' '}
|
||||||
|
{AusruestungStatusLabel[AusruestungStatus.Beschaedigt].toLowerCase()} gemeldet.
|
||||||
|
</Typography>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: (
|
||||||
|
<Box component="ul" sx={{ m: 0, pl: 2 }}>
|
||||||
|
{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 (
|
||||||
|
<Box key={item.id} component="li" sx={{ mb: 0.5 }}>
|
||||||
|
<Link
|
||||||
|
component={RouterLink}
|
||||||
|
to={`/ausruestung/${item.id}`}
|
||||||
|
color="inherit"
|
||||||
|
underline="hover"
|
||||||
|
sx={{ fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
{item.bezeichnung}
|
||||||
|
</Link>
|
||||||
|
{item.kategorie_kurzname ? ` (${item.kategorie_kurzname})` : ''}
|
||||||
|
{' — '}
|
||||||
|
<Typography component="span" variant="body2">
|
||||||
|
{tageText}
|
||||||
|
{item.naechste_pruefung_am
|
||||||
|
? ` (${formatDate(item.naechste_pruefung_am)})`
|
||||||
|
: ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. In maintenance
|
||||||
|
if (stats.inWartung > 0) {
|
||||||
|
groups.push({
|
||||||
|
key: 'wartung',
|
||||||
|
severity: 'warning',
|
||||||
|
title: `${AusruestungStatusLabel[AusruestungStatus.InWartung]} (${stats.inWartung})`,
|
||||||
|
content: (
|
||||||
|
<Typography variant="body2">
|
||||||
|
{stats.inWartung} Ausrüstungsteil{stats.inWartung === 1 ? '' : 'e'}{' '}
|
||||||
|
{stats.inWartung === 1 ? 'befindet' : 'befinden'} sich derzeit in Wartung.
|
||||||
|
</Typography>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing to show
|
||||||
|
if (groups.length === 0) {
|
||||||
|
if (hideWhenEmpty) return null;
|
||||||
|
return (
|
||||||
|
<Alert severity="success">
|
||||||
|
Alle Ausrüstung ist einsatzbereit. Keine Auffälligkeiten in den nächsten{' '}
|
||||||
|
{daysAhead} Tagen.
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
{groups.map(({ key, severity, title, content }) => (
|
||||||
|
<Alert key={key} severity={severity} variant="outlined">
|
||||||
|
<AlertTitle sx={{ fontWeight: 600 }}>{title}</AlertTitle>
|
||||||
|
{content}
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EquipmentAlerts;
|
||||||
@@ -10,11 +10,10 @@ import {
|
|||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Dashboard as DashboardIcon,
|
Dashboard as DashboardIcon,
|
||||||
LocalFireDepartment,
|
|
||||||
DirectionsCar,
|
DirectionsCar,
|
||||||
Build,
|
Build,
|
||||||
People,
|
People,
|
||||||
CalendarMonth as CalendarIcon,
|
Air,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
@@ -32,11 +31,6 @@ const navigationItems: NavigationItem[] = [
|
|||||||
icon: <DashboardIcon />,
|
icon: <DashboardIcon />,
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
text: 'Einsätze',
|
|
||||||
icon: <LocalFireDepartment />,
|
|
||||||
path: '/einsaetze',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
text: 'Fahrzeuge',
|
text: 'Fahrzeuge',
|
||||||
icon: <DirectionsCar />,
|
icon: <DirectionsCar />,
|
||||||
@@ -53,9 +47,9 @@ const navigationItems: NavigationItem[] = [
|
|||||||
path: '/mitglieder',
|
path: '/mitglieder',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Dienstkalender',
|
text: 'Atemschutz',
|
||||||
icon: <CalendarIcon />,
|
icon: <Air />,
|
||||||
path: '/kalender',
|
path: '/atemschutz',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
777
frontend/src/pages/Atemschutz.tsx
Normal file
777
frontend/src/pages/Atemschutz.tsx
Normal file
@@ -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<StatCardProps> = ({ label, value, color, bgcolor }) => (
|
||||||
|
<Card sx={{ bgcolor: bgcolor || 'background.paper', height: '100%' }}>
|
||||||
|
<CardContent sx={{ textAlign: 'center', py: 2 }}>
|
||||||
|
<Typography variant="h3" fontWeight={700} color={color || 'text.primary'}>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color={color ? color : 'text.secondary'} sx={{ mt: 0.5 }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Main Page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Atemschutz() {
|
||||||
|
const notification = useNotification();
|
||||||
|
|
||||||
|
// Data state
|
||||||
|
const [traeger, setTraeger] = useState<AtemschutzUebersicht[]>([]);
|
||||||
|
const [stats, setStats] = useState<AtemschutzStats | null>(null);
|
||||||
|
const [members, setMembers] = useState<MemberListItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
// Dialog state
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState<AtemschutzFormState>({ ...EMPTY_FORM });
|
||||||
|
const [dialogLoading, setDialogLoading] = useState(false);
|
||||||
|
const [dialogError, setDialogError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Delete confirmation
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(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 (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Container maxWidth="lg">
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
|
||||||
|
Atemschutzverwaltung
|
||||||
|
</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={stats.untersuchungAbgelaufen > 0 ? 'error.main' : 'text.secondary'}
|
||||||
|
fontWeight={stats.untersuchungAbgelaufen > 0 ? 600 : 400}
|
||||||
|
>
|
||||||
|
{stats.untersuchungAbgelaufen} Untersuchung abgelaufen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Stats cards */}
|
||||||
|
{!loading && stats && (
|
||||||
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<StatCard
|
||||||
|
label="Einsatzbereit"
|
||||||
|
value={stats.einsatzbereit}
|
||||||
|
color="#fff"
|
||||||
|
bgcolor="success.main"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<StatCard label="Lehrgang absolviert" value={stats.mitLehrgang} />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<StatCard label="Untersuchung gültig" value={stats.untersuchungGueltig} />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<StatCard label="Leistungstest gültig" value={stats.leistungstestGueltig} />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search bar */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<TextField
|
||||||
|
placeholder="Suchen (Name, E-Mail, Dienstgrad...)"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ minWidth: 280, maxWidth: 480, width: '100%' }}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Search />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</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 state */}
|
||||||
|
{!loading && !error && filtered.length === 0 && (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||||
|
<Typography variant="h6" color="text.secondary">
|
||||||
|
{traeger.length === 0
|
||||||
|
? 'Keine Atemschutzträger vorhanden'
|
||||||
|
: 'Keine Ergebnisse gefunden'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{!loading && !error && filtered.length > 0 && (
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Dienstgrad</TableCell>
|
||||||
|
<TableCell align="center">Lehrgang</TableCell>
|
||||||
|
<TableCell>Untersuchung gültig bis</TableCell>
|
||||||
|
<TableCell>Leistungstest gültig bis</TableCell>
|
||||||
|
<TableCell align="center">Status</TableCell>
|
||||||
|
<TableCell align="right">Aktionen</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{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 (
|
||||||
|
<TableRow key={item.id} hover>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" fontWeight={500}>
|
||||||
|
{getDisplayName(item)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{item.user_email}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{item.dienstgrad || '—'}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
{item.atemschutz_lehrgang ? (
|
||||||
|
<Tooltip title={item.lehrgang_datum ? `Lehrgang am ${formatDate(item.lehrgang_datum)}` : 'Lehrgang absolviert'}>
|
||||||
|
<Check color="success" fontSize="small" />
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Close color="disabled" fontSize="small" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
item.untersuchung_tage_rest !== null
|
||||||
|
? item.untersuchung_tage_rest < 0
|
||||||
|
? `Seit ${Math.abs(item.untersuchung_tage_rest)} Tagen abgelaufen`
|
||||||
|
: `Noch ${item.untersuchung_tage_rest} Tage gültig`
|
||||||
|
: 'Keine Untersuchung eingetragen'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" color={untersuchungColor} fontWeight={500}>
|
||||||
|
{formatDate(item.untersuchung_gueltig_bis)}
|
||||||
|
</Typography>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
item.leistungstest_tage_rest !== null
|
||||||
|
? item.leistungstest_tage_rest < 0
|
||||||
|
? `Seit ${Math.abs(item.leistungstest_tage_rest)} Tagen abgelaufen`
|
||||||
|
: `Noch ${item.leistungstest_tage_rest} Tage gültig`
|
||||||
|
: 'Kein Leistungstest eingetragen'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" color={leistungstestColor} fontWeight={500}>
|
||||||
|
{formatDate(item.leistungstest_gueltig_bis)}
|
||||||
|
</Typography>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<Chip
|
||||||
|
label={item.einsatzbereit ? 'Einsatzbereit' : 'Nicht einsatzbereit'}
|
||||||
|
color={item.einsatzbereit ? 'success' : 'error'}
|
||||||
|
size="small"
|
||||||
|
variant="filled"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Tooltip title="Bearbeiten">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleOpenEdit(item)}
|
||||||
|
sx={{ minWidth: 'auto', mr: 0.5 }}
|
||||||
|
>
|
||||||
|
<Edit fontSize="small" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Löschen">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => setDeleteId(item.id)}
|
||||||
|
sx={{ minWidth: 'auto' }}
|
||||||
|
>
|
||||||
|
<Delete fontSize="small" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* FAB to create */}
|
||||||
|
<Fab
|
||||||
|
color="primary"
|
||||||
|
aria-label="Atemschutzträger hinzufügen"
|
||||||
|
sx={{ position: 'fixed', bottom: 32, right: 32 }}
|
||||||
|
onClick={handleOpenCreate}
|
||||||
|
>
|
||||||
|
<Add />
|
||||||
|
</Fab>
|
||||||
|
|
||||||
|
{/* ── Add / Edit Dialog ───────────────────────────────────────────── */}
|
||||||
|
<Dialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onClose={handleDialogClose}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingId ? 'Atemschutzträger bearbeiten' : 'Neuen Atemschutzträger anlegen'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{dialogError && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2, mt: 1 }}>
|
||||||
|
{dialogError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||||||
|
{/* User selection (only when creating) */}
|
||||||
|
{!editingId && (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<FormControl fullWidth size="small" required>
|
||||||
|
<InputLabel>Mitglied</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={form.user_id}
|
||||||
|
label="Mitglied"
|
||||||
|
onChange={(e) => handleFormChange('user_id', e.target.value)}
|
||||||
|
>
|
||||||
|
{availableMembers.map((m) => {
|
||||||
|
const displayName = [m.family_name, m.given_name]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ') || m.name || m.email;
|
||||||
|
return (
|
||||||
|
<MenuItem key={m.id} value={m.id}>
|
||||||
|
{displayName}
|
||||||
|
{m.dienstgrad ? ` (${m.dienstgrad})` : ''}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{availableMembers.length === 0 && (
|
||||||
|
<MenuItem disabled value="">
|
||||||
|
Keine verfügbaren Mitglieder
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lehrgang */}
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
Lehrgang
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={form.atemschutz_lehrgang}
|
||||||
|
onChange={(e) => handleFormChange('atemschutz_lehrgang', e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Lehrgang absolviert"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<TextField
|
||||||
|
label="Lehrgang Datum"
|
||||||
|
type="date"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={form.lehrgang_datum}
|
||||||
|
onChange={(e) => handleFormChange('lehrgang_datum', e.target.value)}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Untersuchung */}
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1, mt: 1 }}>
|
||||||
|
Untersuchung
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<TextField
|
||||||
|
label="Untersuchung Datum"
|
||||||
|
type="date"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={form.untersuchung_datum}
|
||||||
|
onChange={(e) => handleFormChange('untersuchung_datum', e.target.value)}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<TextField
|
||||||
|
label="Gültig bis"
|
||||||
|
type="date"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={form.untersuchung_gueltig_bis}
|
||||||
|
onChange={(e) => handleFormChange('untersuchung_gueltig_bis', e.target.value)}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel>Ergebnis</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={form.untersuchung_ergebnis}
|
||||||
|
label="Ergebnis"
|
||||||
|
onChange={(e) => handleFormChange('untersuchung_ergebnis', e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="">— Nicht angegeben —</MenuItem>
|
||||||
|
{(Object.keys(UntersuchungErgebnisLabel) as UntersuchungErgebnis[]).map(
|
||||||
|
(key) => (
|
||||||
|
<MenuItem key={key} value={key}>
|
||||||
|
{UntersuchungErgebnisLabel[key]}
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Leistungstest */}
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1, mt: 1 }}>
|
||||||
|
Leistungstest
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<TextField
|
||||||
|
label="Leistungstest Datum"
|
||||||
|
type="date"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={form.leistungstest_datum}
|
||||||
|
onChange={(e) => handleFormChange('leistungstest_datum', e.target.value)}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<TextField
|
||||||
|
label="Gültig bis"
|
||||||
|
type="date"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={form.leistungstest_gueltig_bis}
|
||||||
|
onChange={(e) => handleFormChange('leistungstest_gueltig_bis', e.target.value)}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={form.leistungstest_bestanden}
|
||||||
|
onChange={(e) => handleFormChange('leistungstest_bestanden', e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Leistungstest bestanden"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Bemerkung */}
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TextField
|
||||||
|
label="Bemerkung"
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={form.bemerkung}
|
||||||
|
onChange={(e) => handleFormChange('bemerkung', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleDialogClose} disabled={dialogLoading}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={dialogLoading}
|
||||||
|
startIcon={dialogLoading ? <CircularProgress size={16} /> : undefined}
|
||||||
|
>
|
||||||
|
{editingId ? 'Speichern' : 'Anlegen'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ── Delete Confirmation Dialog ──────────────────────────────────── */}
|
||||||
|
<Dialog open={deleteId !== null} onClose={() => setDeleteId(null)}>
|
||||||
|
<DialogTitle>Atemschutzträger löschen</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Soll dieser Atemschutzträger-Eintrag wirklich gelöscht werden? Diese Aktion kann
|
||||||
|
nicht rückgängig gemacht werden.
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteId(null)} disabled={deleteLoading}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
disabled={deleteLoading}
|
||||||
|
startIcon={deleteLoading ? <CircularProgress size={16} /> : undefined}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Atemschutz;
|
||||||
@@ -7,9 +7,6 @@ import {
|
|||||||
Fade,
|
Fade,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
People,
|
|
||||||
Warning,
|
|
||||||
EventNote,
|
|
||||||
DirectionsCar,
|
DirectionsCar,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
@@ -23,13 +20,18 @@ import NextcloudTalkWidget from '../components/dashboard/NextcloudTalkWidget';
|
|||||||
import StatsCard from '../components/dashboard/StatsCard';
|
import StatsCard from '../components/dashboard/StatsCard';
|
||||||
import ActivityFeed from '../components/dashboard/ActivityFeed';
|
import ActivityFeed from '../components/dashboard/ActivityFeed';
|
||||||
import InspectionAlerts from '../components/vehicles/InspectionAlerts';
|
import InspectionAlerts from '../components/vehicles/InspectionAlerts';
|
||||||
|
import EquipmentAlerts from '../components/equipment/EquipmentAlerts';
|
||||||
|
import AtemschutzDashboardCard from '../components/atemschutz/AtemschutzDashboardCard';
|
||||||
import { vehiclesApi } from '../services/vehicles';
|
import { vehiclesApi } from '../services/vehicles';
|
||||||
|
import { equipmentApi } from '../services/equipment';
|
||||||
import type { VehicleStats } from '../types/vehicle.types';
|
import type { VehicleStats } from '../types/vehicle.types';
|
||||||
|
import type { VehicleEquipmentWarning } from '../types/equipment.types';
|
||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [dataLoading, setDataLoading] = useState(true);
|
const [dataLoading, setDataLoading] = useState(true);
|
||||||
const [vehicleStats, setVehicleStats] = useState<VehicleStats | null>(null);
|
const [vehicleStats, setVehicleStats] = useState<VehicleStats | null>(null);
|
||||||
|
const [vehicleWarnings, setVehicleWarnings] = useState<VehicleEquipmentWarning[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -43,6 +45,13 @@ function Dashboard() {
|
|||||||
// Non-critical — KPI will fall back to placeholder
|
// 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);
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -80,62 +89,12 @@ function Dashboard() {
|
|||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats Cards Row */}
|
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
|
||||||
{dataLoading ? (
|
|
||||||
<SkeletonCard variant="basic" />
|
|
||||||
) : (
|
|
||||||
<Fade in={true} timeout={600} style={{ transitionDelay: '200ms' }}>
|
|
||||||
<Box>
|
|
||||||
<StatsCard
|
|
||||||
title="Aktive Mitglieder"
|
|
||||||
value="24"
|
|
||||||
icon={People}
|
|
||||||
color="primary.main"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Fade>
|
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
|
||||||
{dataLoading ? (
|
|
||||||
<SkeletonCard variant="basic" />
|
|
||||||
) : (
|
|
||||||
<Fade in={true} timeout={600} style={{ transitionDelay: '250ms' }}>
|
|
||||||
<Box>
|
|
||||||
<StatsCard
|
|
||||||
title="Einsätze (Jahr)"
|
|
||||||
value="18"
|
|
||||||
icon={Warning}
|
|
||||||
color="error.main"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Fade>
|
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
|
||||||
{dataLoading ? (
|
|
||||||
<SkeletonCard variant="basic" />
|
|
||||||
) : (
|
|
||||||
<Fade in={true} timeout={600} style={{ transitionDelay: '300ms' }}>
|
|
||||||
<Box>
|
|
||||||
<StatsCard
|
|
||||||
title="Offene Aufgaben"
|
|
||||||
value="7"
|
|
||||||
icon={EventNote}
|
|
||||||
color="warning.main"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Fade>
|
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Live vehicle KPI — einsatzbereit count from API */}
|
{/* Live vehicle KPI — einsatzbereit count from API */}
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
{dataLoading ? (
|
{dataLoading ? (
|
||||||
<SkeletonCard variant="basic" />
|
<SkeletonCard variant="basic" />
|
||||||
) : (
|
) : (
|
||||||
<Fade in={true} timeout={600} style={{ transitionDelay: '350ms' }}>
|
<Fade in={true} timeout={600} style={{ transitionDelay: '200ms' }}>
|
||||||
<Box>
|
<Box>
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Fahrzeuge einsatzbereit"
|
title="Fahrzeuge einsatzbereit"
|
||||||
@@ -147,6 +106,11 @@ function Dashboard() {
|
|||||||
icon={DirectionsCar}
|
icon={DirectionsCar}
|
||||||
color="success.main"
|
color="success.main"
|
||||||
/>
|
/>
|
||||||
|
{vehicleWarnings.length > 0 && (
|
||||||
|
<Typography variant="caption" color="warning.main" sx={{ mt: 0.5, display: 'block', textAlign: 'center' }}>
|
||||||
|
{new Set(vehicleWarnings.map(w => w.fahrzeug_id)).size} Fzg. mit Ausrüstungsmangel
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Fade>
|
</Fade>
|
||||||
)}
|
)}
|
||||||
@@ -161,6 +125,24 @@ function Dashboard() {
|
|||||||
</Fade>
|
</Fade>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* Equipment Alerts Panel */}
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '400ms' }}>
|
||||||
|
<Box>
|
||||||
|
<EquipmentAlerts daysAhead={30} hideWhenEmpty={true} />
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Atemschutz Status Card */}
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '420ms' }}>
|
||||||
|
<Box>
|
||||||
|
<AtemschutzDashboardCard />
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
{/* Service Integration Cards */}
|
{/* Service Integration Cards */}
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||||
|
|||||||
64
frontend/src/services/atemschutz.ts
Normal file
64
frontend/src/services/atemschutz.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
import type {
|
||||||
|
AtemschutzUebersicht,
|
||||||
|
AtemschutzTraeger,
|
||||||
|
AtemschutzStats,
|
||||||
|
CreateAtemschutzPayload,
|
||||||
|
UpdateAtemschutzPayload,
|
||||||
|
} from '../types/atemschutz.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 atemschutzApi = {
|
||||||
|
async getAll(): Promise<AtemschutzUebersicht[]> {
|
||||||
|
return unwrap(
|
||||||
|
api.get<{ success: boolean; data: AtemschutzUebersicht[] }>('/api/atemschutz')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id: string): Promise<AtemschutzUebersicht> {
|
||||||
|
return unwrap(
|
||||||
|
api.get<{ success: boolean; data: AtemschutzUebersicht }>(`/api/atemschutz/${id}`)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getStats(): Promise<AtemschutzStats> {
|
||||||
|
return unwrap(
|
||||||
|
api.get<{ success: boolean; data: AtemschutzStats }>('/api/atemschutz/stats')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(payload: CreateAtemschutzPayload): Promise<AtemschutzTraeger> {
|
||||||
|
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<AtemschutzTraeger> {
|
||||||
|
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<void> {
|
||||||
|
await api.delete(`/api/atemschutz/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
76
frontend/src/types/atemschutz.types.ts
Normal file
76
frontend/src/types/atemschutz.types.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// Atemschutz (Breathing Apparatus) Carrier Management — Frontend Type Definitions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export type UntersuchungErgebnis = 'tauglich' | 'bedingt_tauglich' | 'nicht_tauglich';
|
||||||
|
|
||||||
|
export const UntersuchungErgebnisLabel: Record<UntersuchungErgebnis, string> = {
|
||||||
|
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<Partial<CreateAtemschutzPayload>, 'user_id'>;
|
||||||
Reference in New Issue
Block a user