diff --git a/backend/src/controllers/atemschutz.controller.ts b/backend/src/controllers/atemschutz.controller.ts index 20b1c09..2e7cbba 100644 --- a/backend/src/controllers/atemschutz.controller.ts +++ b/backend/src/controllers/atemschutz.controller.ts @@ -171,9 +171,9 @@ class AtemschutzController { user_id: item.user_id, typ: 'atemschutz_expiry', titel: item.untersuchung_status === 'abgelaufen' - ? 'G26 Untersuchung abgelaufen' - : 'G26 Untersuchung läuft bald ab', - nachricht: `Ihre G26 Untersuchung ${item.untersuchung_status === 'abgelaufen' ? 'ist abgelaufen' : 'läuft bald ab'}.`, + ? 'Atemschutztauglichkeitsuntersuchung abgelaufen' + : 'Atemschutztauglichkeitsuntersuchung läuft bald ab', + nachricht: `Ihre Atemschutztauglichkeitsuntersuchung ${item.untersuchung_status === 'abgelaufen' ? 'ist abgelaufen' : 'läuft bald ab'}.`, schwere: item.untersuchung_status === 'abgelaufen' ? 'fehler' : 'warnung', quell_typ: 'atemschutz_untersuchung', quell_id: item.id, diff --git a/backend/src/controllers/member.controller.ts b/backend/src/controllers/member.controller.ts index 188bfc6..de9d233 100644 --- a/backend/src/controllers/member.controller.ts +++ b/backend/src/controllers/member.controller.ts @@ -45,6 +45,8 @@ class MemberController { search, page, pageSize, + sortBy, + sortDir, } = req.query as Record; // Arrays can be sent as ?status[]=aktiv&status[]=jugend or CSV @@ -62,6 +64,8 @@ class MemberController { dienstgrad: normalizeArray(dienstgradParam) as any, page: page ? parseInt(page, 10) || 1 : 1, pageSize: pageSize ? (parseInt(pageSize, 10) === 0 ? 0 : Math.min(parseInt(pageSize, 10) || 25, 100)) : 25, + sortBy, + sortDir: sortDir === 'desc' ? 'desc' : sortDir === 'asc' ? 'asc' : undefined, }); res.status(200).json({ diff --git a/backend/src/models/atemschutz.model.ts b/backend/src/models/atemschutz.model.ts index 922b40a..425f26e 100644 --- a/backend/src/models/atemschutz.model.ts +++ b/backend/src/models/atemschutz.model.ts @@ -15,6 +15,7 @@ export interface AtemschutzTraeger { id: string; user_id: string; atemschutz_lehrgang: boolean; + lehrgang_theorie_only: boolean; lehrgang_datum: Date | null; untersuchung_datum: Date | null; untersuchung_gueltig_bis: Date | null; diff --git a/backend/src/models/member.model.ts b/backend/src/models/member.model.ts index 0a6b913..e851b28 100644 --- a/backend/src/models/member.model.ts +++ b/backend/src/models/member.model.ts @@ -166,6 +166,8 @@ export interface MemberFilters { dienstgrad?: DienstgradEnum[]; page?: number; // 1-based pageSize?: number; + sortBy?: string; + sortDir?: 'asc' | 'desc'; } // ============================================================ diff --git a/backend/src/services/atemschutz.service.ts b/backend/src/services/atemschutz.service.ts index 05cd678..25268a6 100644 --- a/backend/src/services/atemschutz.service.ts +++ b/backend/src/services/atemschutz.service.ts @@ -229,19 +229,46 @@ class AtemschutzService { */ async syncLehrgangFromKurse(): Promise<{ processed: number }> { try { - const result = await pool.query(` - INSERT INTO atemschutz_traeger (id, user_id, atemschutz_lehrgang, lehrgang_datum) - SELECT uuid_generate_v4(), a.user_id, true, MIN(a.kurs_datum) + // Pass 1: Full lehrgang — AT20/AGL with 'mit Erfolg' or 'mit ausgezeichnetem Erfolg' + const fullResult = await pool.query(` + INSERT INTO atemschutz_traeger (id, user_id, atemschutz_lehrgang, lehrgang_datum, lehrgang_theorie_only) + SELECT uuid_generate_v4(), a.user_id, true, MIN(a.kurs_datum), false FROM ausbildung a - WHERE a.kurs_kurzbezeichnung = 'AT20' - AND a.erfolgscode = 'mit Erfolg' + WHERE ( + (TRIM(a.kurs_kurzbezeichnung) = 'AT20' AND TRIM(a.erfolgscode) IN ('mit Erfolg', 'mit ausgezeichnetem Erfolg')) + OR + (TRIM(a.kurs_kurzbezeichnung) = 'AGL' AND TRIM(a.erfolgscode) IN ('mit Erfolg', 'mit ausgezeichnetem Erfolg')) + ) GROUP BY a.user_id ON CONFLICT (user_id) DO UPDATE SET atemschutz_lehrgang = true, + lehrgang_theorie_only = false, lehrgang_datum = COALESCE(atemschutz_traeger.lehrgang_datum, EXCLUDED.lehrgang_datum), updated_at = NOW() `); - const processed = result.rowCount ?? 0; + + // Pass 2: Theory-only — AT20 with 'mit Erfolg Theorie', only for users without a full lehrgang + const theorieResult = await pool.query(` + INSERT INTO atemschutz_traeger (id, user_id, atemschutz_lehrgang, lehrgang_datum, lehrgang_theorie_only) + SELECT uuid_generate_v4(), a.user_id, true, MIN(a.kurs_datum), true + FROM ausbildung a + WHERE TRIM(a.kurs_kurzbezeichnung) = 'AT20' + AND TRIM(a.erfolgscode) = 'mit Erfolg Theorie' + AND NOT EXISTS ( + SELECT 1 FROM atemschutz_traeger at2 + WHERE at2.user_id = a.user_id + AND at2.atemschutz_lehrgang = true + AND at2.lehrgang_theorie_only = false + ) + GROUP BY a.user_id + ON CONFLICT (user_id) DO UPDATE + SET atemschutz_lehrgang = true, + lehrgang_theorie_only = true, + lehrgang_datum = COALESCE(atemschutz_traeger.lehrgang_datum, EXCLUDED.lehrgang_datum), + updated_at = NOW() + `); + + const processed = (fullResult.rowCount ?? 0) + (theorieResult.rowCount ?? 0); logger.info('AT20 Atemschutz-Sync abgeschlossen', { processed }); return { processed }; } catch (error) { diff --git a/backend/src/services/member.service.ts b/backend/src/services/member.service.ts index 3730f48..702bb97 100644 --- a/backend/src/services/member.service.ts +++ b/backend/src/services/member.service.ts @@ -133,6 +133,8 @@ class MemberService { dienstgrad, page = 1, pageSize = 25, + sortBy, + sortDir, } = filters ?? {}; const conditions: string[] = ['u.is_active = TRUE']; @@ -165,6 +167,20 @@ class MemberService { const whereClause = `WHERE ${conditions.join(' AND ')}`; const fetchAll = pageSize === 0; + // Build ORDER BY — whitelist columns to prevent SQL injection + const SORT_COLUMNS: Record = { + family_name: 'u.family_name', + eintrittsdatum: 'mp.eintrittsdatum', + dienstgrad: 'mp.dienstgrad', + status: 'mp.status', + fdisk_standesbuch_nr: 'mp.fdisk_standesbuch_nr', + }; + const defaultOrder = 'u.family_name ASC NULLS LAST, u.given_name ASC NULLS LAST'; + const dir = sortDir === 'desc' ? 'DESC' : 'ASC'; + const orderBy = sortBy && SORT_COLUMNS[sortBy] + ? `${SORT_COLUMNS[sortBy]} ${dir} NULLS LAST, u.family_name ASC NULLS LAST` + : defaultOrder; + let dataQuery: string; if (fetchAll) { dataQuery = ` @@ -186,7 +202,7 @@ class MemberService { FROM users u LEFT JOIN mitglieder_profile mp ON mp.user_id = u.id ${whereClause} - ORDER BY u.family_name ASC NULLS LAST, u.given_name ASC NULLS LAST + ORDER BY ${orderBy} `; } else { const offset = (page - 1) * pageSize; @@ -209,7 +225,7 @@ class MemberService { FROM users u LEFT JOIN mitglieder_profile mp ON mp.user_id = u.id ${whereClause} - ORDER BY u.family_name ASC NULLS LAST, u.given_name ASC NULLS LAST + ORDER BY ${orderBy} LIMIT $${paramIdx} OFFSET $${paramIdx + 1} `; values.push(pageSize, offset); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 653589c..cb233d0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,6 +25,7 @@ import PersoenlicheAusruestungNeu from './pages/PersoenlicheAusruestungNeu'; import PersoenlicheAusruestungDetail from './pages/PersoenlicheAusruestungDetail'; import PersoenlicheAusruestungEdit from './pages/PersoenlicheAusruestungEdit'; import Atemschutz from './pages/Atemschutz'; +import AtemschutzDetail from './pages/AtemschutzDetail'; import Mitglieder from './pages/Mitglieder'; import MitgliedDetail from './pages/MitgliedDetail'; import Kalender from './pages/Kalender'; @@ -234,6 +235,14 @@ function App() { } /> + + + + } + /> = { const TAB_RESET: Record = { atemschutz: [ - { key: 'reset-atemschutz', label: 'Atemschutz-Daten zuruecksetzen', description: 'Alle Atemschutz-Eintraege (G26-Untersuchungen, Leistungstests, Lehrgaenge) fuer alle Benutzer loeschen.' }, + { key: 'reset-atemschutz', label: 'Atemschutz-Daten zuruecksetzen', description: 'Alle Atemschutz-Eintraege (Atemschutztauglichkeitsuntersuchungen, Leistungstests, Lehrgaenge) fuer alle Benutzer loeschen.' }, ], fahrzeuge: [ { key: 'reset-persoenliche-ausruestung', label: 'Persoenliche Ausruestung zuruecksetzen', description: 'Alle persoenlichen Ausruestungszuweisungen loeschen. Zuordnungen in Anfragen werden zurueckgesetzt.' }, diff --git a/frontend/src/components/templates/DataTable.tsx b/frontend/src/components/templates/DataTable.tsx index 523dbfe..f76f7e2 100644 --- a/frontend/src/components/templates/DataTable.tsx +++ b/frontend/src/components/templates/DataTable.tsx @@ -50,6 +50,12 @@ export interface DataTableProps { maxHeight?: number | string; size?: 'small' | 'medium'; dense?: boolean; + /** Controlled sort: current sort column key (null = no sort). When onSortChange is provided, sorting is external. */ + controlledSortKey?: string | null; + /** Controlled sort: current direction */ + controlledSortDir?: 'asc' | 'desc'; + /** Callback for controlled sort mode. When provided, DataTable delegates sorting to the parent. */ + onSortChange?: (key: string | null, dir: 'asc' | 'desc') => void; } /** Universal data table with search, sorting, and pagination. */ @@ -74,26 +80,45 @@ export function DataTable({ maxHeight, size = 'small', dense = false, + controlledSortKey, + controlledSortDir, + onSortChange, }: DataTableProps) { + const controlled = !!onSortChange; const [search, setSearch] = useState(''); - const [sortKey, setSortKey] = useState(null); - const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); + const [internalSortKey, setInternalSortKey] = useState(null); + const [internalSortDir, setInternalSortDir] = useState<'asc' | 'desc'>('asc'); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(defaultRowsPerPage); + const activeSortKey = controlled ? (controlledSortKey ?? null) : internalSortKey; + const activeSortDir = controlled ? (controlledSortDir ?? 'asc') : internalSortDir; + const handleSort = useCallback((key: string) => { - if (sortKey === key) { - if (sortDir === 'asc') { - setSortDir('desc'); + if (controlled) { + if (activeSortKey === key) { + if (activeSortDir === 'asc') { + onSortChange!(key, 'desc'); + } else { + onSortChange!(null, 'asc'); + } } else { - setSortKey(null); - setSortDir('asc'); + onSortChange!(key, 'asc'); } } else { - setSortKey(key); - setSortDir('asc'); + if (internalSortKey === key) { + if (internalSortDir === 'asc') { + setInternalSortDir('desc'); + } else { + setInternalSortKey(null); + setInternalSortDir('asc'); + } + } else { + setInternalSortKey(key); + setInternalSortDir('asc'); + } } - }, [sortKey, sortDir]); + }, [controlled, activeSortKey, activeSortDir, onSortChange, internalSortKey, internalSortDir]); const filteredData = useMemo(() => { if (!search.trim()) return data; @@ -108,17 +133,18 @@ export function DataTable({ }, [data, search, columns]); const sortedData = useMemo(() => { - if (!sortKey) return filteredData; + if (controlled) return filteredData; // server already sorted + if (!activeSortKey) return filteredData; return [...filteredData].sort((a, b) => { - const aVal = (a as Record)[sortKey]; - const bVal = (b as Record)[sortKey]; + const aVal = (a as Record)[activeSortKey]; + const bVal = (b as Record)[activeSortKey]; if (aVal == null && bVal == null) return 0; if (aVal == null) return 1; if (bVal == null) return -1; const cmp = String(aVal).localeCompare(String(bVal), undefined, { numeric: true }); - return sortDir === 'asc' ? cmp : -cmp; + return activeSortDir === 'asc' ? cmp : -cmp; }); - }, [filteredData, sortKey, sortDir]); + }, [filteredData, activeSortKey, activeSortDir, controlled]); const paginatedData = paginationEnabled ? sortedData.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) @@ -170,8 +196,8 @@ export function DataTable({ > {col.label} - {sortKey === col.key && ( - sortDir === 'asc' + {activeSortKey === col.key && ( + activeSortDir === 'asc' ? : )} diff --git a/frontend/src/pages/Atemschutz.tsx b/frontend/src/pages/Atemschutz.tsx index b8e4ec6..df98ab8 100644 --- a/frontend/src/pages/Atemschutz.tsx +++ b/frontend/src/pages/Atemschutz.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Alert, Box, @@ -136,6 +137,7 @@ const StatCard: React.FC = ({ label, value, color, bgcolor }) => // ── Main Page ──────────────────────────────────────────────────────────────── function Atemschutz() { + const navigate = useNavigate(); const notification = useNotification(); const { hasPermission } = usePermissionContext(); const canViewAll = hasPermission('atemschutz:view'); @@ -556,6 +558,7 @@ function Atemschutz() { columns={columns} data={filtered} rowKey={(item) => item.id} + onRowClick={(item) => navigate(`/atemschutz/${item.user_id}`)} emptyMessage={traeger.length === 0 ? 'Keine Atemschutzträger vorhanden' : 'Keine Ergebnisse gefunden'} searchEnabled={false} paginationEnabled={false} diff --git a/frontend/src/pages/AtemschutzDetail.tsx b/frontend/src/pages/AtemschutzDetail.tsx new file mode 100644 index 0000000..2cebe85 --- /dev/null +++ b/frontend/src/pages/AtemschutzDetail.tsx @@ -0,0 +1,647 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Checkbox, + Chip, + CircularProgress, + Container, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormControlLabel, + Grid, + InputLabel, + MenuItem, + Select, + TextField, + Typography, +} from '@mui/material'; +import { ArrowBack, Delete, Edit } from '@mui/icons-material'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { ConfirmDialog } from '../components/templates'; +import { atemschutzApi } from '../services/atemschutz'; +import { useNotification } from '../contexts/NotificationContext'; +import { usePermissionContext } from '../contexts/PermissionContext'; +import { toGermanDate, fromGermanDate, isValidGermanDate } from '../utils/dateInput'; +import type { + AtemschutzUebersicht, + UpdateAtemschutzPayload, + UntersuchungErgebnis, +} from '../types/atemschutz.types'; +import { UntersuchungErgebnisLabel } from '../types/atemschutz.types'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function formatDate(iso: string | null): string { + if (!iso) return '—'; + return new Date(iso).toLocaleDateString('de-AT'); +} + +function ValidityChip({ + date, + gueltig, + tageRest, +}: { + date: string | null; + gueltig: boolean; + tageRest: number | null; +}) { + if (!date) return ; + const formatted = new Date(date).toLocaleDateString('de-AT'); + const color: 'success' | 'warning' | 'error' = + !gueltig ? 'error' : tageRest !== null && tageRest < 90 ? 'warning' : 'success'; + const suffix = + !gueltig ? ' (abgelaufen)' : tageRest !== null && tageRest < 90 ? ` (${tageRest} Tage)` : ''; + return ; +} + +function FieldRow({ label, value }: { label: string; value: React.ReactNode }) { + if (value === null || value === undefined) return null; + return ( + + {label} + {value} + + ); +} + +// ── Form state (mirrors Atemschutz.tsx) ───────────────────────────────────── + +interface AtemschutzFormState { + 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 = { + atemschutz_lehrgang: false, + lehrgang_datum: '', + untersuchung_datum: '', + untersuchung_gueltig_bis: '', + untersuchung_ergebnis: '', + leistungstest_datum: '', + leistungstest_gueltig_bis: '', + leistungstest_bestanden: false, + bemerkung: '', +}; + +// ── Main Page ─────────────────────────────────────────────────────────────── + +function AtemschutzDetail() { + const { userId } = useParams<{ userId: string }>(); + const navigate = useNavigate(); + const notification = useNotification(); + const { hasPermission } = usePermissionContext(); + const canWrite = hasPermission('atemschutz:create'); + const canDelete = hasPermission('atemschutz:delete'); + + // Data state + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Edit dialog state + const [dialogOpen, setDialogOpen] = useState(false); + const [form, setForm] = useState({ ...EMPTY_FORM }); + const [dialogLoading, setDialogLoading] = useState(false); + const [dialogError, setDialogError] = useState(null); + const [dateErrors, setDateErrors] = useState>>({}); + + // Delete state + const [deleteOpen, setDeleteOpen] = useState(false); + const [deleteLoading, setDeleteLoading] = useState(false); + + // ── Data loading ────────────────────────────────────────────────────────── + + const fetchData = useCallback(async () => { + if (!userId) return; + try { + setLoading(true); + setError(null); + const result = await atemschutzApi.getByUserId(userId); + setData(result); + } catch { + setError('Atemschutz-Daten konnten nicht geladen werden.'); + } finally { + setLoading(false); + } + }, [userId]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // ── Display name ────────────────────────────────────────────────────────── + + function getDisplayName(item: AtemschutzUebersicht): string { + if (item.user_family_name || item.user_given_name) { + return [item.user_given_name, item.user_family_name].filter(Boolean).join(' '); + } + return item.user_name || item.user_email; + } + + // ── Edit dialog handlers ────────────────────────────────────────────────── + + const handleOpenEdit = () => { + if (!data) return; + setForm({ + atemschutz_lehrgang: data.atemschutz_lehrgang, + lehrgang_datum: toGermanDate(data.lehrgang_datum), + untersuchung_datum: toGermanDate(data.untersuchung_datum), + untersuchung_gueltig_bis: toGermanDate(data.untersuchung_gueltig_bis), + untersuchung_ergebnis: data.untersuchung_ergebnis || '', + leistungstest_datum: toGermanDate(data.leistungstest_datum), + leistungstest_gueltig_bis: toGermanDate(data.leistungstest_gueltig_bis), + leistungstest_bestanden: data.leistungstest_bestanden || false, + bemerkung: data.bemerkung || '', + }); + setDialogError(null); + setDateErrors({}); + setDialogOpen(true); + }; + + const handleDialogClose = () => { + setDialogOpen(false); + setForm({ ...EMPTY_FORM }); + setDialogError(null); + setDateErrors({}); + }; + + const handleFormChange = (field: keyof AtemschutzFormState, value: string | boolean) => { + setForm((prev) => ({ ...prev, [field]: value })); + }; + + const normalizeDate = (val: string | undefined): string | undefined => { + if (!val) return undefined; + const iso = fromGermanDate(val); + return iso || undefined; + }; + + const handleSubmit = async () => { + if (!data) return; + setDialogError(null); + setDateErrors({}); + + // Validate date fields + const newDateErrors: Partial> = {}; + const dateFields: (keyof AtemschutzFormState)[] = [ + 'lehrgang_datum', 'untersuchung_datum', 'untersuchung_gueltig_bis', + 'leistungstest_datum', 'leistungstest_gueltig_bis', + ]; + for (const field of dateFields) { + const val = form[field] as string; + if (val && !isValidGermanDate(val)) { + newDateErrors[field] = 'Ungültiges Datum (Format: TT.MM.JJJJ)'; + } + } + if (form.untersuchung_datum && form.untersuchung_gueltig_bis && + isValidGermanDate(form.untersuchung_datum) && isValidGermanDate(form.untersuchung_gueltig_bis)) { + const from = new Date(fromGermanDate(form.untersuchung_datum)!); + const to = new Date(fromGermanDate(form.untersuchung_gueltig_bis)!); + if (to < from) { + newDateErrors['untersuchung_gueltig_bis'] = 'Muss nach dem Untersuchungsdatum liegen'; + } + } + if (form.leistungstest_datum && form.leistungstest_gueltig_bis && + isValidGermanDate(form.leistungstest_datum) && isValidGermanDate(form.leistungstest_gueltig_bis)) { + const from = new Date(fromGermanDate(form.leistungstest_datum)!); + const to = new Date(fromGermanDate(form.leistungstest_gueltig_bis)!); + if (to < from) { + newDateErrors['leistungstest_gueltig_bis'] = 'Muss nach dem Leistungstestdatum liegen'; + } + } + if (Object.keys(newDateErrors).length > 0) { + setDateErrors(newDateErrors); + return; + } + + setDialogLoading(true); + try { + const payload: UpdateAtemschutzPayload = { + atemschutz_lehrgang: form.atemschutz_lehrgang, + lehrgang_datum: normalizeDate(form.lehrgang_datum || undefined), + untersuchung_datum: normalizeDate(form.untersuchung_datum || undefined), + untersuchung_gueltig_bis: normalizeDate(form.untersuchung_gueltig_bis || undefined), + untersuchung_ergebnis: (form.untersuchung_ergebnis as UntersuchungErgebnis) || null, + leistungstest_datum: normalizeDate(form.leistungstest_datum || undefined), + leistungstest_gueltig_bis: normalizeDate(form.leistungstest_gueltig_bis || undefined), + leistungstest_bestanden: form.leistungstest_bestanden, + bemerkung: form.bemerkung || undefined, + }; + await atemschutzApi.update(data.id, payload); + notification.showSuccess('Atemschutzträger erfolgreich aktualisiert.'); + 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 (!data) return; + setDeleteLoading(true); + try { + await atemschutzApi.delete(data.id); + notification.showSuccess('Atemschutzträger erfolgreich gelöscht.'); + navigate('/atemschutz'); + } catch (err: any) { + notification.showError(err?.message || 'Löschen fehlgeschlagen.'); + } finally { + setDeleteLoading(false); + } + }; + + // ── Render ──────────────────────────────────────────────────────────────── + + return ( + + + {/* Back button */} + + + {/* Loading */} + {loading && ( + + + + )} + + {/* Error */} + {error && ( + + {error} + + )} + + {/* No record */} + {!loading && !error && !data && ( + + Kein Atemschutz-Eintrag für dieses Mitglied vorhanden. + + )} + + {/* Data */} + {!loading && !error && data && ( + <> + {/* Header */} + + + + {getDisplayName(data)} + + + + + {canWrite && ( + + )} + {canDelete && ( + + )} + + + + {/* Detail cards */} + + {/* Lehrgang */} + + + + + Lehrgang + + + + + + + + {/* Atemschutztauglichkeitsuntersuchung */} + + + + + Atemschutztauglichkeitsuntersuchung + + + + } + /> + + + + + + {/* Leistungstest */} + + + + + Leistungstest (Finnentest) + + + + } + /> + + + + + + {/* Bemerkung */} + {data.bemerkung && ( + + + + + Bemerkung + + + {data.bemerkung} + + + + + )} + + + {/* Dienstgrad / Mitglied-Status info */} + {(data.dienstgrad || data.mitglied_status) && ( + + {data.dienstgrad && ( + + Dienstgrad: {data.dienstgrad} + + )} + {data.mitglied_status && ( + + Status: {data.mitglied_status} + + )} + + )} + + )} + + {/* ── Edit Dialog ──────────────────────────────────────────────────── */} + + Atemschutzträger bearbeiten + + {dialogError && ( + + {dialogError} + + )} + + + {/* Lehrgang */} + + + Lehrgang + + + + handleFormChange('atemschutz_lehrgang', e.target.checked)} + /> + } + label="Lehrgang absolviert" + /> + + + handleFormChange('lehrgang_datum', e.target.value)} + error={!!dateErrors.lehrgang_datum} + helperText={dateErrors.lehrgang_datum ?? 'Format: 01.03.2025'} + InputLabelProps={{ shrink: true }} + /> + + + {/* Untersuchung */} + + + Atemschutztauglichkeitsuntersuchung + + + + handleFormChange('untersuchung_datum', e.target.value)} + error={!!dateErrors.untersuchung_datum} + helperText={dateErrors.untersuchung_datum ?? 'Format: 08.02.2023'} + InputLabelProps={{ shrink: true }} + /> + + + handleFormChange('untersuchung_gueltig_bis', e.target.value)} + error={!!dateErrors.untersuchung_gueltig_bis} + helperText={dateErrors.untersuchung_gueltig_bis ?? 'Format: 08.02.2028'} + InputLabelProps={{ shrink: true }} + /> + + + + Ergebnis + + + + + {/* Leistungstest */} + + + Leistungstest + + + + handleFormChange('leistungstest_datum', e.target.value)} + error={!!dateErrors.leistungstest_datum} + helperText={dateErrors.leistungstest_datum ?? 'Format: 25.08.2025'} + InputLabelProps={{ shrink: true }} + /> + + + handleFormChange('leistungstest_gueltig_bis', e.target.value)} + error={!!dateErrors.leistungstest_gueltig_bis} + helperText={dateErrors.leistungstest_gueltig_bis ?? 'Format: 25.08.2026'} + InputLabelProps={{ shrink: true }} + /> + + + handleFormChange('leistungstest_bestanden', e.target.checked)} + /> + } + label="Leistungstest bestanden" + /> + + + {/* Bemerkung */} + + handleFormChange('bemerkung', e.target.value)} + /> + + + + + + + + + + {/* ── Delete Confirmation ──────────────────────────────────────────── */} + setDeleteOpen(false)} + onConfirm={handleDeleteConfirm} + title="Atemschutzträger löschen" + message="Soll dieser Atemschutzträger-Eintrag wirklich gelöscht werden? Diese Aktion kann nicht rückgängig gemacht werden." + confirmLabel="Löschen" + confirmColor="error" + isLoading={deleteLoading} + /> + + + ); +} + +export default AtemschutzDetail; diff --git a/frontend/src/pages/MitgliedDetail.tsx b/frontend/src/pages/MitgliedDetail.tsx index b74b83a..42bb849 100644 --- a/frontend/src/pages/MitgliedDetail.tsx +++ b/frontend/src/pages/MitgliedDetail.tsx @@ -29,7 +29,6 @@ import { Person as PersonIcon, Phone as PhoneIcon, Security as SecurityIcon, - History as HistoryIcon, DriveEta as DriveEtaIcon, CheckCircle as CheckCircleIcon, HighlightOff as HighlightOffIcon, @@ -80,92 +79,6 @@ function useCurrentUserId(): string | undefined { return (user as any)?.id; } -// ---------------------------------------------------------------- -// Rank history timeline component -// ---------------------------------------------------------------- -interface RankTimelineProps { - entries: NonNullable; -} - -function RankTimeline({ entries }: RankTimelineProps) { - if (entries.length === 0) { - return ( - - Keine Dienstgradänderungen eingetragen. - - ); - } - - return ( - - {entries.map((entry, idx) => ( - - {/* Timeline dot */} - - - - - {/* Content */} - - - {entry.dienstgrad_neu} - - {entry.dienstgrad_alt && ( - - vorher: {entry.dienstgrad_alt} - - )} - - - {new Date(entry.datum).toLocaleDateString('de-AT')} - - {entry.durch_user_name && ( - - · durch {entry.durch_user_name} - - )} - - {entry.bemerkung && ( - - {entry.bemerkung} - - )} - - - ))} - - ); -} - // ---------------------------------------------------------------- // Read-only field row // ---------------------------------------------------------------- @@ -616,16 +529,6 @@ function MitgliedDetail() { ))} - handleFieldChange('dienstgrad_seit', e.target.value || undefined)} - InputLabelProps={{ shrink: true }} - /> - - handleFieldChange('austrittsdatum', e.target.value || undefined)} - InputLabelProps={{ shrink: true }} - /> - - @@ -718,12 +605,6 @@ function MitgliedDetail() { : null } /> - {editMode && isOwnProfile ? ( - - )} @@ -823,7 +698,10 @@ function MitgliedDetail() { {/* Driving licenses */} - + { setActiveTab(2); setQualSubTab(4); } : undefined} + > } title="Führerscheinklassen" @@ -854,18 +732,62 @@ function MitgliedDetail() { - {/* Rank history */} - - - } - title="Dienstgrad-Verlauf" - /> - - - - - + {/* Atemschutz overview */} + {atemschutz && ( + + { setActiveTab(2); setQualSubTab(0); }} + > + } + title="Atemschutz" + action={ + : } + label={atemschutz.einsatzbereit ? 'Einsatzbereit' : 'Nicht einsatzbereit'} + color={atemschutz.einsatzbereit ? 'success' : 'error'} + size="small" + /> + } + /> + + } label="Bestanden" color="success" size="small" variant="outlined" /> + : } label="Nicht absolviert" color="default" size="small" variant="outlined" /> + } + /> + + : null + } + /> + + : null + } + /> + + + + )} {/* Remarks — Kommandant/Admin only */} {canWrite && ( @@ -1044,7 +966,7 @@ function MitgliedDetail() { - G26.3 Untersuchung + Atemschutztauglichkeitsuntersuchung ([]); const [page, setPage] = useState(0); // MUI uses 0-based const [pageSize, setPageSize] = useState(25); + const [sortKey, setSortKey] = useState(null); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); // Track previous debounced search to reset page const prevSearch = useRef(debouncedSearch); @@ -109,6 +112,8 @@ function Mitglieder() { dienstgrad: selectedDienstgrad.length > 0 ? selectedDienstgrad : undefined, page: page + 1, // convert to 1-based for API pageSize: pageSize === -1 ? 0 : pageSize, // -1 = MUI "Alle" sentinel → 0 = backend "no limit" + sortBy: sortKey ?? undefined, + sortDir: sortKey ? sortDir : undefined, }); setMembers(items); setTotal(t); @@ -117,7 +122,7 @@ function Mitglieder() { } finally { setLoading(false); } - }, [debouncedSearch, selectedStatus, selectedDienstgrad, page, pageSize]); + }, [debouncedSearch, selectedStatus, selectedDienstgrad, page, pageSize, sortKey, sortDir]); useEffect(() => { if (debouncedSearch !== prevSearch.current) { @@ -147,6 +152,12 @@ function Mitglieder() { navigate(`/mitglieder/${userId}`); }; + const handleSortChange = (key: string | null, dir: 'asc' | 'desc') => { + setSortKey(key); + setSortDir(dir); + setPage(0); + }; + const handleRemoveStatusChip = (status: StatusEnum) => { setSelectedStatus((prev) => prev.filter((s) => s !== status)); setPage(0); @@ -320,7 +331,7 @@ function Mitglieder() { : }, { key: 'eintrittsdatum', label: 'Eintrittsdatum', render: (member) => member.eintrittsdatum - ? new Date(member.eintrittsdatum).toLocaleDateString('de-AT') + ? toGermanDate(member.eintrittsdatum) : '—' }, { key: 'telefon_mobil', label: 'Telefon', render: (member) => formatPhone(member.telefon_mobil) }, @@ -333,6 +344,9 @@ function Mitglieder() { emptyIcon={} searchEnabled={false} paginationEnabled={false} + controlledSortKey={sortKey} + controlledSortDir={sortDir} + onSortChange={handleSortChange} stickyHeader /> params.append('status[]', s)); filters.dienstgrad?.forEach((d) => params.append('dienstgrad[]', d)); + if (filters.sortBy) params.append('sortBy', filters.sortBy); + if (filters.sortDir) params.append('sortDir', filters.sortDir); + return params; } diff --git a/frontend/src/types/member.types.ts b/frontend/src/types/member.types.ts index 3be529a..d1c3989 100644 --- a/frontend/src/types/member.types.ts +++ b/frontend/src/types/member.types.ts @@ -134,6 +134,8 @@ export interface MemberFilters { dienstgrad?: DienstgradEnum[]; page?: number; pageSize?: number; + sortBy?: string; + sortDir?: 'asc' | 'desc'; } export type CreateMemberProfileData = Partial>; diff --git a/sync/src/db.ts b/sync/src/db.ts index 2e5dc87..e6f7393 100644 --- a/sync/src/db.ts +++ b/sync/src/db.ts @@ -349,8 +349,7 @@ export async function syncLehrgangToAtemschutz(pool: Pool): Promise { SET atemschutz_lehrgang = true, lehrgang_theorie_only = true, lehrgang_datum = COALESCE(atemschutz_traeger.lehrgang_datum, EXCLUDED.lehrgang_datum), - updated_at = NOW() - WHERE atemschutz_traeger.lehrgang_theorie_only IS DISTINCT FROM false` + updated_at = NOW()` ); log(`Lehrgang-Sync: ${theorieResult.rowCount ?? 0} theory-only lehrgang rows upserted`); } @@ -388,14 +387,21 @@ export async function syncUntersuchungenToAtemschutz(pool: Pool): Promise ); log(`Untersuchungen→Atemschutz: ${ltResult.rowCount ?? 0} Leistungstest rows synced`); - // --- Atemschutztauglichkeit (G26 medical exam) --- - // Map FDISK ergebnis text to the DB enum (tauglich / bedingt_tauglich / nicht_tauglich) - // Also handle old bugged data where art/ergebnis columns were shifted + // --- Atemschutztauglichkeitsuntersuchung --- + // Take the latest PASSED exam per user, calculate gueltig_bis (default 5 years, + // overridden by "N Jahre" in anmerkungen field, e.g. "3 Jahre"). + // Also handle old bugged data where art/ergebnis columns were shifted. const atResult = await pool.query( - `INSERT INTO atemschutz_traeger (id, user_id, untersuchung_datum, untersuchung_ergebnis) + `INSERT INTO atemschutz_traeger (id, user_id, untersuchung_datum, untersuchung_gueltig_bis, untersuchung_ergebnis) SELECT uuid_generate_v4(), sub.user_id, sub.datum, + sub.datum + make_interval(years => + CASE + WHEN sub.real_anmerkungen ~ '(\\d+)\\s*[Jj]ahre' + THEN (regexp_match(sub.real_anmerkungen, '(\\d+)\\s*[Jj]ahre'))[1]::int + ELSE 5 + END + ), CASE - WHEN sub.ergebnis_text ILIKE '%nicht%tauglich%' THEN 'nicht_tauglich' WHEN sub.ergebnis_text ILIKE '%bedingt%tauglich%' THEN 'bedingt_tauglich' WHEN sub.ergebnis_text ILIKE '%tauglich%' THEN 'tauglich' ELSE NULL @@ -403,20 +409,30 @@ export async function syncUntersuchungenToAtemschutz(pool: Pool): Promise FROM ( SELECT DISTINCT ON (user_id) user_id, datum, CASE - -- correct data: ergebnis has the result text WHEN TRIM(art) = 'Atemschutztauglichkeit' THEN ergebnis - -- bugged data: art has the result, anmerkungen has the real art WHEN TRIM(anmerkungen) = 'Atemschutztauglichkeit' THEN art ELSE ergebnis - END AS ergebnis_text + END AS ergebnis_text, + CASE + WHEN TRIM(art) = 'Atemschutztauglichkeit' THEN anmerkungen + ELSE NULL + END AS real_anmerkungen FROM untersuchungen WHERE (TRIM(art) = 'Atemschutztauglichkeit' OR TRIM(anmerkungen) = 'Atemschutztauglichkeit') AND datum IS NOT NULL + AND ( + -- correct data: ergebnis has the result — must be tauglich (not "nicht tauglich") + (TRIM(art) = 'Atemschutztauglichkeit' AND ergebnis ILIKE '%tauglich%' AND ergebnis NOT ILIKE '%nicht%tauglich%') + OR + -- bugged data: art has the result + (TRIM(anmerkungen) = 'Atemschutztauglichkeit' AND art ILIKE '%tauglich%' AND art NOT ILIKE '%nicht%tauglich%') + ) ORDER BY user_id, datum DESC ) sub ON CONFLICT (user_id) DO UPDATE - SET untersuchung_datum = EXCLUDED.untersuchung_datum, - untersuchung_ergebnis = COALESCE(EXCLUDED.untersuchung_ergebnis, atemschutz_traeger.untersuchung_ergebnis), + SET untersuchung_datum = EXCLUDED.untersuchung_datum, + untersuchung_gueltig_bis = EXCLUDED.untersuchung_gueltig_bis, + untersuchung_ergebnis = COALESCE(EXCLUDED.untersuchung_ergebnis, atemschutz_traeger.untersuchung_ergebnis), updated_at = NOW()` ); log(`Untersuchungen→Atemschutz: ${atResult.rowCount ?? 0} Atemschutztauglichkeit rows synced`);