change dat format in member overview, sync exams to atemschutz tool, rework member detail page

This commit is contained in:
Matthias Hochmeister
2026-04-20 10:32:20 +02:00
parent d5291360c9
commit 752dfe474c
16 changed files with 874 additions and 182 deletions

View File

@@ -171,9 +171,9 @@ class AtemschutzController {
user_id: item.user_id, user_id: item.user_id,
typ: 'atemschutz_expiry', typ: 'atemschutz_expiry',
titel: item.untersuchung_status === 'abgelaufen' titel: item.untersuchung_status === 'abgelaufen'
? 'G26 Untersuchung abgelaufen' ? 'Atemschutztauglichkeitsuntersuchung abgelaufen'
: 'G26 Untersuchung läuft bald ab', : 'Atemschutztauglichkeitsuntersuchung läuft bald ab',
nachricht: `Ihre G26 Untersuchung ${item.untersuchung_status === 'abgelaufen' ? 'ist abgelaufen' : 'läuft bald ab'}.`, nachricht: `Ihre Atemschutztauglichkeitsuntersuchung ${item.untersuchung_status === 'abgelaufen' ? 'ist abgelaufen' : 'läuft bald ab'}.`,
schwere: item.untersuchung_status === 'abgelaufen' ? 'fehler' : 'warnung', schwere: item.untersuchung_status === 'abgelaufen' ? 'fehler' : 'warnung',
quell_typ: 'atemschutz_untersuchung', quell_typ: 'atemschutz_untersuchung',
quell_id: item.id, quell_id: item.id,

View File

@@ -45,6 +45,8 @@ class MemberController {
search, search,
page, page,
pageSize, pageSize,
sortBy,
sortDir,
} = req.query as Record<string, string | undefined>; } = req.query as Record<string, string | undefined>;
// Arrays can be sent as ?status[]=aktiv&status[]=jugend or CSV // Arrays can be sent as ?status[]=aktiv&status[]=jugend or CSV
@@ -62,6 +64,8 @@ class MemberController {
dienstgrad: normalizeArray(dienstgradParam) as any, dienstgrad: normalizeArray(dienstgradParam) as any,
page: page ? parseInt(page, 10) || 1 : 1, page: page ? parseInt(page, 10) || 1 : 1,
pageSize: pageSize ? (parseInt(pageSize, 10) === 0 ? 0 : Math.min(parseInt(pageSize, 10) || 25, 100)) : 25, 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({ res.status(200).json({

View File

@@ -15,6 +15,7 @@ export interface AtemschutzTraeger {
id: string; id: string;
user_id: string; user_id: string;
atemschutz_lehrgang: boolean; atemschutz_lehrgang: boolean;
lehrgang_theorie_only: boolean;
lehrgang_datum: Date | null; lehrgang_datum: Date | null;
untersuchung_datum: Date | null; untersuchung_datum: Date | null;
untersuchung_gueltig_bis: Date | null; untersuchung_gueltig_bis: Date | null;

View File

@@ -166,6 +166,8 @@ export interface MemberFilters {
dienstgrad?: DienstgradEnum[]; dienstgrad?: DienstgradEnum[];
page?: number; // 1-based page?: number; // 1-based
pageSize?: number; pageSize?: number;
sortBy?: string;
sortDir?: 'asc' | 'desc';
} }
// ============================================================ // ============================================================

View File

@@ -229,19 +229,46 @@ class AtemschutzService {
*/ */
async syncLehrgangFromKurse(): Promise<{ processed: number }> { async syncLehrgangFromKurse(): Promise<{ processed: number }> {
try { try {
const result = await pool.query(` // Pass 1: Full lehrgang — AT20/AGL with 'mit Erfolg' or 'mit ausgezeichnetem Erfolg'
INSERT INTO atemschutz_traeger (id, user_id, atemschutz_lehrgang, lehrgang_datum) const fullResult = await pool.query(`
SELECT uuid_generate_v4(), a.user_id, true, MIN(a.kurs_datum) 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 FROM ausbildung a
WHERE a.kurs_kurzbezeichnung = 'AT20' WHERE (
AND a.erfolgscode = 'mit Erfolg' (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 GROUP BY a.user_id
ON CONFLICT (user_id) DO UPDATE ON CONFLICT (user_id) DO UPDATE
SET atemschutz_lehrgang = true, SET atemschutz_lehrgang = true,
lehrgang_theorie_only = false,
lehrgang_datum = COALESCE(atemschutz_traeger.lehrgang_datum, EXCLUDED.lehrgang_datum), lehrgang_datum = COALESCE(atemschutz_traeger.lehrgang_datum, EXCLUDED.lehrgang_datum),
updated_at = NOW() 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 }); logger.info('AT20 Atemschutz-Sync abgeschlossen', { processed });
return { processed }; return { processed };
} catch (error) { } catch (error) {

View File

@@ -133,6 +133,8 @@ class MemberService {
dienstgrad, dienstgrad,
page = 1, page = 1,
pageSize = 25, pageSize = 25,
sortBy,
sortDir,
} = filters ?? {}; } = filters ?? {};
const conditions: string[] = ['u.is_active = TRUE']; const conditions: string[] = ['u.is_active = TRUE'];
@@ -165,6 +167,20 @@ class MemberService {
const whereClause = `WHERE ${conditions.join(' AND ')}`; const whereClause = `WHERE ${conditions.join(' AND ')}`;
const fetchAll = pageSize === 0; const fetchAll = pageSize === 0;
// Build ORDER BY — whitelist columns to prevent SQL injection
const SORT_COLUMNS: Record<string, string> = {
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; let dataQuery: string;
if (fetchAll) { if (fetchAll) {
dataQuery = ` dataQuery = `
@@ -186,7 +202,7 @@ class MemberService {
FROM users u FROM users u
LEFT JOIN mitglieder_profile mp ON mp.user_id = u.id LEFT JOIN mitglieder_profile mp ON mp.user_id = u.id
${whereClause} ${whereClause}
ORDER BY u.family_name ASC NULLS LAST, u.given_name ASC NULLS LAST ORDER BY ${orderBy}
`; `;
} else { } else {
const offset = (page - 1) * pageSize; const offset = (page - 1) * pageSize;
@@ -209,7 +225,7 @@ class MemberService {
FROM users u FROM users u
LEFT JOIN mitglieder_profile mp ON mp.user_id = u.id LEFT JOIN mitglieder_profile mp ON mp.user_id = u.id
${whereClause} ${whereClause}
ORDER BY u.family_name ASC NULLS LAST, u.given_name ASC NULLS LAST ORDER BY ${orderBy}
LIMIT $${paramIdx} OFFSET $${paramIdx + 1} LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
`; `;
values.push(pageSize, offset); values.push(pageSize, offset);

View File

@@ -25,6 +25,7 @@ import PersoenlicheAusruestungNeu from './pages/PersoenlicheAusruestungNeu';
import PersoenlicheAusruestungDetail from './pages/PersoenlicheAusruestungDetail'; import PersoenlicheAusruestungDetail from './pages/PersoenlicheAusruestungDetail';
import PersoenlicheAusruestungEdit from './pages/PersoenlicheAusruestungEdit'; import PersoenlicheAusruestungEdit from './pages/PersoenlicheAusruestungEdit';
import Atemschutz from './pages/Atemschutz'; import Atemschutz from './pages/Atemschutz';
import AtemschutzDetail from './pages/AtemschutzDetail';
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';
@@ -234,6 +235,14 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/atemschutz/:userId"
element={
<ProtectedRoute>
<AtemschutzDetail />
</ProtectedRoute>
}
/>
<Route <Route
path="/atemschutz" path="/atemschutz"
element={ element={

View File

@@ -51,7 +51,7 @@ const TAB_CLEANUP: Record<string, CleanupSection[]> = {
const TAB_RESET: Record<string, ResetSection[]> = { const TAB_RESET: Record<string, ResetSection[]> = {
atemschutz: [ 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: [ fahrzeuge: [
{ key: 'reset-persoenliche-ausruestung', label: 'Persoenliche Ausruestung zuruecksetzen', description: 'Alle persoenlichen Ausruestungszuweisungen loeschen. Zuordnungen in Anfragen werden zurueckgesetzt.' }, { key: 'reset-persoenliche-ausruestung', label: 'Persoenliche Ausruestung zuruecksetzen', description: 'Alle persoenlichen Ausruestungszuweisungen loeschen. Zuordnungen in Anfragen werden zurueckgesetzt.' },

View File

@@ -50,6 +50,12 @@ export interface DataTableProps<T> {
maxHeight?: number | string; maxHeight?: number | string;
size?: 'small' | 'medium'; size?: 'small' | 'medium';
dense?: boolean; 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. */ /** Universal data table with search, sorting, and pagination. */
@@ -74,26 +80,45 @@ export function DataTable<T>({
maxHeight, maxHeight,
size = 'small', size = 'small',
dense = false, dense = false,
controlledSortKey,
controlledSortDir,
onSortChange,
}: DataTableProps<T>) { }: DataTableProps<T>) {
const controlled = !!onSortChange;
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<string | null>(null); const [internalSortKey, setInternalSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); const [internalSortDir, setInternalSortDir] = useState<'asc' | 'desc'>('asc');
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(defaultRowsPerPage); const [rowsPerPage, setRowsPerPage] = useState(defaultRowsPerPage);
const activeSortKey = controlled ? (controlledSortKey ?? null) : internalSortKey;
const activeSortDir = controlled ? (controlledSortDir ?? 'asc') : internalSortDir;
const handleSort = useCallback((key: string) => { const handleSort = useCallback((key: string) => {
if (sortKey === key) { if (controlled) {
if (sortDir === 'asc') { if (activeSortKey === key) {
setSortDir('desc'); if (activeSortDir === 'asc') {
onSortChange!(key, 'desc');
} else { } else {
setSortKey(null); onSortChange!(null, 'asc');
setSortDir('asc');
} }
} else { } else {
setSortKey(key); onSortChange!(key, 'asc');
setSortDir('asc');
} }
}, [sortKey, sortDir]); } else {
if (internalSortKey === key) {
if (internalSortDir === 'asc') {
setInternalSortDir('desc');
} else {
setInternalSortKey(null);
setInternalSortDir('asc');
}
} else {
setInternalSortKey(key);
setInternalSortDir('asc');
}
}
}, [controlled, activeSortKey, activeSortDir, onSortChange, internalSortKey, internalSortDir]);
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
if (!search.trim()) return data; if (!search.trim()) return data;
@@ -108,17 +133,18 @@ export function DataTable<T>({
}, [data, search, columns]); }, [data, search, columns]);
const sortedData = useMemo(() => { const sortedData = useMemo(() => {
if (!sortKey) return filteredData; if (controlled) return filteredData; // server already sorted
if (!activeSortKey) return filteredData;
return [...filteredData].sort((a, b) => { return [...filteredData].sort((a, b) => {
const aVal = (a as Record<string, unknown>)[sortKey]; const aVal = (a as Record<string, unknown>)[activeSortKey];
const bVal = (b as Record<string, unknown>)[sortKey]; const bVal = (b as Record<string, unknown>)[activeSortKey];
if (aVal == null && bVal == null) return 0; if (aVal == null && bVal == null) return 0;
if (aVal == null) return 1; if (aVal == null) return 1;
if (bVal == null) return -1; if (bVal == null) return -1;
const cmp = String(aVal).localeCompare(String(bVal), undefined, { numeric: true }); 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 const paginatedData = paginationEnabled
? sortedData.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) ? sortedData.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
@@ -170,8 +196,8 @@ export function DataTable<T>({
> >
<Box display="inline-flex" alignItems="center" gap={0.5}> <Box display="inline-flex" alignItems="center" gap={0.5}>
{col.label} {col.label}
{sortKey === col.key && ( {activeSortKey === col.key && (
sortDir === 'asc' activeSortDir === 'asc'
? <ArrowUpwardIcon sx={{ fontSize: 14 }} /> ? <ArrowUpwardIcon sx={{ fontSize: 14 }} />
: <ArrowDownwardIcon sx={{ fontSize: 14 }} /> : <ArrowDownwardIcon sx={{ fontSize: 14 }} />
)} )}

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react'; import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
Alert, Alert,
Box, Box,
@@ -136,6 +137,7 @@ const StatCard: React.FC<StatCardProps> = ({ label, value, color, bgcolor }) =>
// ── Main Page ──────────────────────────────────────────────────────────────── // ── Main Page ────────────────────────────────────────────────────────────────
function Atemschutz() { function Atemschutz() {
const navigate = useNavigate();
const notification = useNotification(); const notification = useNotification();
const { hasPermission } = usePermissionContext(); const { hasPermission } = usePermissionContext();
const canViewAll = hasPermission('atemschutz:view'); const canViewAll = hasPermission('atemschutz:view');
@@ -556,6 +558,7 @@ function Atemschutz() {
columns={columns} columns={columns}
data={filtered} data={filtered}
rowKey={(item) => item.id} rowKey={(item) => item.id}
onRowClick={(item) => navigate(`/atemschutz/${item.user_id}`)}
emptyMessage={traeger.length === 0 ? 'Keine Atemschutzträger vorhanden' : 'Keine Ergebnisse gefunden'} emptyMessage={traeger.length === 0 ? 'Keine Atemschutzträger vorhanden' : 'Keine Ergebnisse gefunden'}
searchEnabled={false} searchEnabled={false}
paginationEnabled={false} paginationEnabled={false}

View File

@@ -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 <span></span>;
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 <Chip label={`${formatted}${suffix}`} color={color} size="small" variant="outlined" />;
}
function FieldRow({ label, value }: { label: string; value: React.ReactNode }) {
if (value === null || value === undefined) return null;
return (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 0.5 }}>
<Typography variant="body2" color="text.secondary">{label}</Typography>
<Typography variant="body2" fontWeight={500} component="div">{value}</Typography>
</Box>
);
}
// ── 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<AtemschutzUebersicht | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Edit dialog state
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState<AtemschutzFormState>({ ...EMPTY_FORM });
const [dialogLoading, setDialogLoading] = useState(false);
const [dialogError, setDialogError] = useState<string | null>(null);
const [dateErrors, setDateErrors] = useState<Partial<Record<keyof AtemschutzFormState, string>>>({});
// 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<Record<keyof AtemschutzFormState, string>> = {};
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 (
<DashboardLayout>
<Container maxWidth="md">
{/* Back button */}
<Button
startIcon={<ArrowBack />}
onClick={() => navigate('/atemschutz')}
sx={{ mb: 2 }}
>
Zurück zur Übersicht
</Button>
{/* Loading */}
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
)}
{/* Error */}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{/* No record */}
{!loading && !error && !data && (
<Alert severity="info">
Kein Atemschutz-Eintrag für dieses Mitglied vorhanden.
</Alert>
)}
{/* Data */}
{!loading && !error && data && (
<>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="h5" fontWeight={600}>
{getDisplayName(data)}
</Typography>
<Chip
label={data.einsatzbereit ? 'Einsatzbereit' : 'Nicht einsatzbereit'}
color={data.einsatzbereit ? 'success' : 'error'}
size="small"
variant="filled"
/>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{canWrite && (
<Button variant="outlined" startIcon={<Edit />} onClick={handleOpenEdit}>
Bearbeiten
</Button>
)}
{canDelete && (
<Button variant="outlined" color="error" startIcon={<Delete />} onClick={() => setDeleteOpen(true)}>
Löschen
</Button>
)}
</Box>
</Box>
{/* Detail cards */}
<Grid container spacing={3}>
{/* Lehrgang */}
<Grid item xs={12} md={6}>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Lehrgang
</Typography>
<FieldRow
label="Absolviert"
value={
data.atemschutz_lehrgang
? `Ja${data.lehrgang_theorie_only ? ' (nur Theorie)' : ''}`
: 'Nein'
}
/>
<FieldRow
label="Datum"
value={formatDate(data.lehrgang_datum)}
/>
</CardContent>
</Card>
</Grid>
{/* Atemschutztauglichkeitsuntersuchung */}
<Grid item xs={12} md={6}>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Atemschutztauglichkeitsuntersuchung
</Typography>
<FieldRow
label="Datum"
value={formatDate(data.untersuchung_datum)}
/>
<FieldRow
label="Gültig bis"
value={
<ValidityChip
date={data.untersuchung_gueltig_bis}
gueltig={data.untersuchung_gueltig}
tageRest={data.untersuchung_tage_rest}
/>
}
/>
<FieldRow
label="Ergebnis"
value={
data.untersuchung_ergebnis
? UntersuchungErgebnisLabel[data.untersuchung_ergebnis]
: '—'
}
/>
</CardContent>
</Card>
</Grid>
{/* Leistungstest */}
<Grid item xs={12} md={6}>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Leistungstest (Finnentest)
</Typography>
<FieldRow
label="Datum"
value={formatDate(data.leistungstest_datum)}
/>
<FieldRow
label="Gültig bis"
value={
<ValidityChip
date={data.leistungstest_gueltig_bis}
gueltig={data.leistungstest_gueltig}
tageRest={data.leistungstest_tage_rest}
/>
}
/>
<FieldRow
label="Bestanden"
value={
data.leistungstest_bestanden === null
? '—'
: data.leistungstest_bestanden ? 'Ja' : 'Nein'
}
/>
</CardContent>
</Card>
</Grid>
{/* Bemerkung */}
{data.bemerkung && (
<Grid item xs={12} md={6}>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Bemerkung
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{data.bemerkung}
</Typography>
</CardContent>
</Card>
</Grid>
)}
</Grid>
{/* Dienstgrad / Mitglied-Status info */}
{(data.dienstgrad || data.mitglied_status) && (
<Box sx={{ mt: 2, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
{data.dienstgrad && (
<Typography variant="body2" color="text.secondary">
Dienstgrad: {data.dienstgrad}
</Typography>
)}
{data.mitglied_status && (
<Typography variant="body2" color="text.secondary">
Status: {data.mitglied_status}
</Typography>
)}
</Box>
)}
</>
)}
{/* ── Edit Dialog ──────────────────────────────────────────────────── */}
<Dialog open={dialogOpen} onClose={handleDialogClose} maxWidth="sm" fullWidth>
<DialogTitle>Atemschutzträger bearbeiten</DialogTitle>
<DialogContent>
{dialogError && (
<Alert severity="error" sx={{ mb: 2, mt: 1 }}>
{dialogError}
</Alert>
)}
<Grid container spacing={2} sx={{ mt: 0.5 }}>
{/* 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"
size="small"
fullWidth
placeholder="TT.MM.JJJJ"
value={form.lehrgang_datum}
onChange={(e) => handleFormChange('lehrgang_datum', e.target.value)}
error={!!dateErrors.lehrgang_datum}
helperText={dateErrors.lehrgang_datum ?? 'Format: 01.03.2025'}
InputLabelProps={{ shrink: true }}
/>
</Grid>
{/* Untersuchung */}
<Grid item xs={12}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1, mt: 1 }}>
Atemschutztauglichkeitsuntersuchung
</Typography>
</Grid>
<Grid item xs={6}>
<TextField
label="Datum"
size="small"
fullWidth
placeholder="TT.MM.JJJJ"
value={form.untersuchung_datum}
onChange={(e) => handleFormChange('untersuchung_datum', e.target.value)}
error={!!dateErrors.untersuchung_datum}
helperText={dateErrors.untersuchung_datum ?? 'Format: 08.02.2023'}
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid item xs={6}>
<TextField
label="Gültig bis"
size="small"
fullWidth
placeholder="TT.MM.JJJJ"
value={form.untersuchung_gueltig_bis}
onChange={(e) => handleFormChange('untersuchung_gueltig_bis', e.target.value)}
error={!!dateErrors.untersuchung_gueltig_bis}
helperText={dateErrors.untersuchung_gueltig_bis ?? 'Format: 08.02.2028'}
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="Datum"
size="small"
fullWidth
placeholder="TT.MM.JJJJ"
value={form.leistungstest_datum}
onChange={(e) => handleFormChange('leistungstest_datum', e.target.value)}
error={!!dateErrors.leistungstest_datum}
helperText={dateErrors.leistungstest_datum ?? 'Format: 25.08.2025'}
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid item xs={6}>
<TextField
label="Gültig bis"
size="small"
fullWidth
placeholder="TT.MM.JJJJ"
value={form.leistungstest_gueltig_bis}
onChange={(e) => handleFormChange('leistungstest_gueltig_bis', e.target.value)}
error={!!dateErrors.leistungstest_gueltig_bis}
helperText={dateErrors.leistungstest_gueltig_bis ?? 'Format: 25.08.2026'}
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}
>
Speichern
</Button>
</DialogActions>
</Dialog>
{/* ── Delete Confirmation ──────────────────────────────────────────── */}
<ConfirmDialog
open={deleteOpen}
onClose={() => 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}
/>
</Container>
</DashboardLayout>
);
}
export default AtemschutzDetail;

View File

@@ -29,7 +29,6 @@ import {
Person as PersonIcon, Person as PersonIcon,
Phone as PhoneIcon, Phone as PhoneIcon,
Security as SecurityIcon, Security as SecurityIcon,
History as HistoryIcon,
DriveEta as DriveEtaIcon, DriveEta as DriveEtaIcon,
CheckCircle as CheckCircleIcon, CheckCircle as CheckCircleIcon,
HighlightOff as HighlightOffIcon, HighlightOff as HighlightOffIcon,
@@ -80,92 +79,6 @@ function useCurrentUserId(): string | undefined {
return (user as any)?.id; return (user as any)?.id;
} }
// ----------------------------------------------------------------
// Rank history timeline component
// ----------------------------------------------------------------
interface RankTimelineProps {
entries: NonNullable<MemberWithProfile['dienstgrad_verlauf']>;
}
function RankTimeline({ entries }: RankTimelineProps) {
if (entries.length === 0) {
return (
<Typography color="text.secondary" variant="body2">
Keine Dienstgradänderungen eingetragen.
</Typography>
);
}
return (
<Stack spacing={0}>
{entries.map((entry, idx) => (
<Box
key={entry.id}
sx={{
display: 'flex',
gap: 2,
position: 'relative',
pb: 2,
'&::before': idx < entries.length - 1 ? {
content: '""',
position: 'absolute',
left: 11,
top: 24,
bottom: 0,
width: 2,
bgcolor: 'divider',
} : {},
}}
>
{/* Timeline dot */}
<Box
sx={{
width: 24,
height: 24,
borderRadius: '50%',
bgcolor: 'primary.main',
flexShrink: 0,
mt: 0.25,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<HistoryIcon sx={{ fontSize: 14, color: 'white' }} />
</Box>
{/* Content */}
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={500}>
{entry.dienstgrad_neu}
</Typography>
{entry.dienstgrad_alt && (
<Typography variant="caption" color="text.secondary">
vorher: {entry.dienstgrad_alt}
</Typography>
)}
<Box sx={{ display: 'flex', gap: 1, mt: 0.5, flexWrap: 'wrap' }}>
<Typography variant="caption" color="text.secondary">
{new Date(entry.datum).toLocaleDateString('de-AT')}
</Typography>
{entry.durch_user_name && (
<Typography variant="caption" color="text.secondary">
· durch {entry.durch_user_name}
</Typography>
)}
</Box>
{entry.bemerkung && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{entry.bemerkung}
</Typography>
)}
</Box>
</Box>
))}
</Stack>
);
}
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// Read-only field row // Read-only field row
// ---------------------------------------------------------------- // ----------------------------------------------------------------
@@ -616,16 +529,6 @@ function MitgliedDetail() {
))} ))}
</TextField> </TextField>
<TextField
label="Dienstgrad seit"
fullWidth
size="small"
placeholder="TT.MM.JJJJ"
value={formData.dienstgrad_seit ?? ''}
onChange={(e) => handleFieldChange('dienstgrad_seit', e.target.value || undefined)}
InputLabelProps={{ shrink: true }}
/>
<TextField <TextField
label="Status" label="Status"
select select
@@ -659,16 +562,6 @@ function MitgliedDetail() {
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
/> />
<TextField
label="Austrittsdatum"
fullWidth
size="small"
placeholder="TT.MM.JJJJ"
value={formData.austrittsdatum ?? ''}
onChange={(e) => handleFieldChange('austrittsdatum', e.target.value || undefined)}
InputLabelProps={{ shrink: true }}
/>
<TextField <TextField
label="Standesbuchnummer" label="Standesbuchnummer"
fullWidth fullWidth
@@ -691,12 +584,6 @@ function MitgliedDetail() {
) : ( ) : (
<> <>
<FieldRow label="Dienstgrad" value={profile?.dienstgrad ?? null} /> <FieldRow label="Dienstgrad" value={profile?.dienstgrad ?? null} />
<FieldRow
label="Dienstgrad seit"
value={profile?.dienstgrad_seit
? new Date(profile.dienstgrad_seit).toLocaleDateString('de-AT')
: null}
/>
<FieldRow label="Status" value={ <FieldRow label="Status" value={
profile?.status profile?.status
? <StatusChip status={profile.status} labelMap={STATUS_LABELS} colorMap={STATUS_COLORS} /> ? <StatusChip status={profile.status} labelMap={STATUS_LABELS} colorMap={STATUS_COLORS} />
@@ -718,12 +605,6 @@ function MitgliedDetail() {
: null : null
} }
/> />
<FieldRow
label="Austrittsdatum"
value={profile?.austrittsdatum
? new Date(profile.austrittsdatum).toLocaleDateString('de-AT')
: null}
/>
{editMode && isOwnProfile ? ( {editMode && isOwnProfile ? (
<Box> <Box>
<TextField <TextField
@@ -739,12 +620,6 @@ function MitgliedDetail() {
)} )}
<FieldRow label="Geburtsort" value={profile?.geburtsort ?? null} /> <FieldRow label="Geburtsort" value={profile?.geburtsort ?? null} />
<FieldRow label="Geschlecht" value={profile?.geschlecht ?? null} /> <FieldRow label="Geschlecht" value={profile?.geschlecht ?? null} />
<FieldRow label="Beruf" value={profile?.beruf ?? null} />
<FieldRow label="Wohnort" value={
profile?.wohnort
? [profile.plz, profile.wohnort].filter(Boolean).join(' ')
: null
} />
</> </>
)} )}
</CardContent> </CardContent>
@@ -823,7 +698,10 @@ function MitgliedDetail() {
{/* Driving licenses */} {/* Driving licenses */}
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
<Card> <Card
sx={!editMode ? { cursor: 'pointer' } : undefined}
onClick={!editMode ? () => { setActiveTab(2); setQualSubTab(4); } : undefined}
>
<CardHeader <CardHeader
avatar={<DriveEtaIcon color="primary" />} avatar={<DriveEtaIcon color="primary" />}
title="Führerscheinklassen" title="Führerscheinklassen"
@@ -854,18 +732,62 @@ function MitgliedDetail() {
</Card> </Card>
</Grid> </Grid>
{/* Rank history */} {/* Atemschutz overview */}
<Grid item xs={12}> {atemschutz && (
<Card> <Grid item xs={12} md={6}>
<Card
sx={{ cursor: 'pointer' }}
onClick={() => { setActiveTab(2); setQualSubTab(0); }}
>
<CardHeader <CardHeader
avatar={<HistoryIcon color="primary" />} avatar={<SecurityIcon color="primary" />}
title="Dienstgrad-Verlauf" title="Atemschutz"
action={
<Chip
icon={atemschutz.einsatzbereit ? <CheckCircleIcon /> : <HighlightOffIcon />}
label={atemschutz.einsatzbereit ? 'Einsatzbereit' : 'Nicht einsatzbereit'}
color={atemschutz.einsatzbereit ? 'success' : 'error'}
size="small"
/>
}
/> />
<CardContent> <CardContent>
<RankTimeline entries={member.dienstgrad_verlauf ?? []} /> <FieldRow
label="Lehrgang"
value={
atemschutz.atemschutz_lehrgang
? <Chip icon={<CheckCircleIcon />} label="Bestanden" color="success" size="small" variant="outlined" />
: <Chip icon={<HighlightOffIcon />} label="Nicht absolviert" color="default" size="small" variant="outlined" />
}
/>
<FieldRow
label="Untersuchung"
value={
atemschutz.untersuchung_gueltig_bis
? <ValidityChip
date={atemschutz.untersuchung_gueltig_bis}
gueltig={atemschutz.untersuchung_gueltig}
tageRest={atemschutz.untersuchung_tage_rest}
/>
: null
}
/>
<FieldRow
label="Leistungstest"
value={
atemschutz.leistungstest_gueltig_bis
? <ValidityChip
date={atemschutz.leistungstest_gueltig_bis}
gueltig={atemschutz.leistungstest_gueltig}
tageRest={atemschutz.leistungstest_tage_rest}
/>
: null
}
/>
</CardContent> </CardContent>
</Card> </Card>
</Grid> </Grid>
)}
{/* Remarks — Kommandant/Admin only */} {/* Remarks — Kommandant/Admin only */}
{canWrite && ( {canWrite && (
@@ -1044,7 +966,7 @@ function MitgliedDetail() {
<Divider sx={{ my: 1.5 }} /> <Divider sx={{ my: 1.5 }} />
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}> <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
G26.3 Untersuchung Atemschutztauglichkeitsuntersuchung
</Typography> </Typography>
<FieldRow <FieldRow
label="Datum" label="Datum"

View File

@@ -30,6 +30,7 @@ import { DataTable, StatusChip } from '../components/templates';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { membersService } from '../services/members'; import { membersService } from '../services/members';
import { toGermanDate } from '../utils/dateInput';
import { import {
MemberListItem, MemberListItem,
StatusEnum, StatusEnum,
@@ -92,6 +93,8 @@ function Mitglieder() {
const [selectedDienstgrad, setSelectedDienstgrad] = useState<DienstgradEnum[]>([]); const [selectedDienstgrad, setSelectedDienstgrad] = useState<DienstgradEnum[]>([]);
const [page, setPage] = useState(0); // MUI uses 0-based const [page, setPage] = useState(0); // MUI uses 0-based
const [pageSize, setPageSize] = useState(25); const [pageSize, setPageSize] = useState(25);
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
// Track previous debounced search to reset page // Track previous debounced search to reset page
const prevSearch = useRef(debouncedSearch); const prevSearch = useRef(debouncedSearch);
@@ -109,6 +112,8 @@ function Mitglieder() {
dienstgrad: selectedDienstgrad.length > 0 ? selectedDienstgrad : undefined, dienstgrad: selectedDienstgrad.length > 0 ? selectedDienstgrad : undefined,
page: page + 1, // convert to 1-based for API page: page + 1, // convert to 1-based for API
pageSize: pageSize === -1 ? 0 : pageSize, // -1 = MUI "Alle" sentinel → 0 = backend "no limit" pageSize: pageSize === -1 ? 0 : pageSize, // -1 = MUI "Alle" sentinel → 0 = backend "no limit"
sortBy: sortKey ?? undefined,
sortDir: sortKey ? sortDir : undefined,
}); });
setMembers(items); setMembers(items);
setTotal(t); setTotal(t);
@@ -117,7 +122,7 @@ function Mitglieder() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [debouncedSearch, selectedStatus, selectedDienstgrad, page, pageSize]); }, [debouncedSearch, selectedStatus, selectedDienstgrad, page, pageSize, sortKey, sortDir]);
useEffect(() => { useEffect(() => {
if (debouncedSearch !== prevSearch.current) { if (debouncedSearch !== prevSearch.current) {
@@ -147,6 +152,12 @@ function Mitglieder() {
navigate(`/mitglieder/${userId}`); navigate(`/mitglieder/${userId}`);
}; };
const handleSortChange = (key: string | null, dir: 'asc' | 'desc') => {
setSortKey(key);
setSortDir(dir);
setPage(0);
};
const handleRemoveStatusChip = (status: StatusEnum) => { const handleRemoveStatusChip = (status: StatusEnum) => {
setSelectedStatus((prev) => prev.filter((s) => s !== status)); setSelectedStatus((prev) => prev.filter((s) => s !== status));
setPage(0); setPage(0);
@@ -320,7 +331,7 @@ function Mitglieder() {
: <Typography variant="body2" color="text.secondary"></Typography> : <Typography variant="body2" color="text.secondary"></Typography>
}, },
{ key: 'eintrittsdatum', label: 'Eintrittsdatum', render: (member) => member.eintrittsdatum { 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) }, { key: 'telefon_mobil', label: 'Telefon', render: (member) => formatPhone(member.telefon_mobil) },
@@ -333,6 +344,9 @@ function Mitglieder() {
emptyIcon={<PeopleIcon sx={{ fontSize: 40, mb: 1, opacity: 0.5 }} />} emptyIcon={<PeopleIcon sx={{ fontSize: 40, mb: 1, opacity: 0.5 }} />}
searchEnabled={false} searchEnabled={false}
paginationEnabled={false} paginationEnabled={false}
controlledSortKey={sortKey}
controlledSortDir={sortDir}
onSortChange={handleSortChange}
stickyHeader stickyHeader
/> />
<TablePagination <TablePagination

View File

@@ -45,6 +45,9 @@ function buildParams(filters?: MemberFilters): URLSearchParams {
filters.status?.forEach((s) => params.append('status[]', s)); filters.status?.forEach((s) => params.append('status[]', s));
filters.dienstgrad?.forEach((d) => params.append('dienstgrad[]', d)); 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; return params;
} }

View File

@@ -134,6 +134,8 @@ export interface MemberFilters {
dienstgrad?: DienstgradEnum[]; dienstgrad?: DienstgradEnum[];
page?: number; page?: number;
pageSize?: number; pageSize?: number;
sortBy?: string;
sortDir?: 'asc' | 'desc';
} }
export type CreateMemberProfileData = Partial<Omit<MitgliederProfile, 'id' | 'user_id' | 'created_at' | 'updated_at'>>; export type CreateMemberProfileData = Partial<Omit<MitgliederProfile, 'id' | 'user_id' | 'created_at' | 'updated_at'>>;

View File

@@ -349,8 +349,7 @@ export async function syncLehrgangToAtemschutz(pool: Pool): Promise<void> {
SET atemschutz_lehrgang = true, SET atemschutz_lehrgang = true,
lehrgang_theorie_only = true, lehrgang_theorie_only = true,
lehrgang_datum = COALESCE(atemschutz_traeger.lehrgang_datum, EXCLUDED.lehrgang_datum), lehrgang_datum = COALESCE(atemschutz_traeger.lehrgang_datum, EXCLUDED.lehrgang_datum),
updated_at = NOW() updated_at = NOW()`
WHERE atemschutz_traeger.lehrgang_theorie_only IS DISTINCT FROM false`
); );
log(`Lehrgang-Sync: ${theorieResult.rowCount ?? 0} theory-only lehrgang rows upserted`); log(`Lehrgang-Sync: ${theorieResult.rowCount ?? 0} theory-only lehrgang rows upserted`);
} }
@@ -388,14 +387,21 @@ export async function syncUntersuchungenToAtemschutz(pool: Pool): Promise<void>
); );
log(`Untersuchungen→Atemschutz: ${ltResult.rowCount ?? 0} Leistungstest rows synced`); log(`Untersuchungen→Atemschutz: ${ltResult.rowCount ?? 0} Leistungstest rows synced`);
// --- Atemschutztauglichkeit (G26 medical exam) --- // --- Atemschutztauglichkeitsuntersuchung ---
// Map FDISK ergebnis text to the DB enum (tauglich / bedingt_tauglich / nicht_tauglich) // Take the latest PASSED exam per user, calculate gueltig_bis (default 5 years,
// Also handle old bugged data where art/ergebnis columns were shifted // 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( 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, 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 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 '%bedingt%tauglich%' THEN 'bedingt_tauglich'
WHEN sub.ergebnis_text ILIKE '%tauglich%' THEN 'tauglich' WHEN sub.ergebnis_text ILIKE '%tauglich%' THEN 'tauglich'
ELSE NULL ELSE NULL
@@ -403,19 +409,29 @@ export async function syncUntersuchungenToAtemschutz(pool: Pool): Promise<void>
FROM ( FROM (
SELECT DISTINCT ON (user_id) user_id, datum, SELECT DISTINCT ON (user_id) user_id, datum,
CASE CASE
-- correct data: ergebnis has the result text
WHEN TRIM(art) = 'Atemschutztauglichkeit' THEN ergebnis WHEN TRIM(art) = 'Atemschutztauglichkeit' THEN ergebnis
-- bugged data: art has the result, anmerkungen has the real art
WHEN TRIM(anmerkungen) = 'Atemschutztauglichkeit' THEN art WHEN TRIM(anmerkungen) = 'Atemschutztauglichkeit' THEN art
ELSE ergebnis 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 FROM untersuchungen
WHERE (TRIM(art) = 'Atemschutztauglichkeit' OR TRIM(anmerkungen) = 'Atemschutztauglichkeit') WHERE (TRIM(art) = 'Atemschutztauglichkeit' OR TRIM(anmerkungen) = 'Atemschutztauglichkeit')
AND datum IS NOT NULL 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 ORDER BY user_id, datum DESC
) sub ) sub
ON CONFLICT (user_id) DO UPDATE ON CONFLICT (user_id) DO UPDATE
SET untersuchung_datum = EXCLUDED.untersuchung_datum, SET untersuchung_datum = EXCLUDED.untersuchung_datum,
untersuchung_gueltig_bis = EXCLUDED.untersuchung_gueltig_bis,
untersuchung_ergebnis = COALESCE(EXCLUDED.untersuchung_ergebnis, atemschutz_traeger.untersuchung_ergebnis), untersuchung_ergebnis = COALESCE(EXCLUDED.untersuchung_ergebnis, atemschutz_traeger.untersuchung_ergebnis),
updated_at = NOW()` updated_at = NOW()`
); );