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,
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,

View File

@@ -45,6 +45,8 @@ class MemberController {
search,
page,
pageSize,
sortBy,
sortDir,
} = req.query as Record<string, string | undefined>;
// 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({

View File

@@ -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;

View File

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

View File

@@ -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) {

View File

@@ -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<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;
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);

View File

@@ -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() {
</ProtectedRoute>
}
/>
<Route
path="/atemschutz/:userId"
element={
<ProtectedRoute>
<AtemschutzDetail />
</ProtectedRoute>
}
/>
<Route
path="/atemschutz"
element={

View File

@@ -51,7 +51,7 @@ const TAB_CLEANUP: Record<string, CleanupSection[]> = {
const TAB_RESET: Record<string, ResetSection[]> = {
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.' },

View File

@@ -50,6 +50,12 @@ export interface DataTableProps<T> {
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<T>({
maxHeight,
size = 'small',
dense = false,
controlledSortKey,
controlledSortDir,
onSortChange,
}: DataTableProps<T>) {
const controlled = !!onSortChange;
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [internalSortKey, setInternalSortKey] = useState<string | null>(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<T>({
}, [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<string, unknown>)[sortKey];
const bVal = (b as Record<string, unknown>)[sortKey];
const aVal = (a as Record<string, unknown>)[activeSortKey];
const bVal = (b as Record<string, unknown>)[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<T>({
>
<Box display="inline-flex" alignItems="center" gap={0.5}>
{col.label}
{sortKey === col.key && (
sortDir === 'asc'
{activeSortKey === col.key && (
activeSortDir === 'asc'
? <ArrowUpwardIcon sx={{ fontSize: 14 }} />
: <ArrowDownwardIcon sx={{ fontSize: 14 }} />
)}

View File

@@ -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<StatCardProps> = ({ 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}

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,
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<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
// ----------------------------------------------------------------
@@ -616,16 +529,6 @@ function MitgliedDetail() {
))}
</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
label="Status"
select
@@ -659,16 +562,6 @@ function MitgliedDetail() {
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
label="Standesbuchnummer"
fullWidth
@@ -691,12 +584,6 @@ function MitgliedDetail() {
) : (
<>
<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={
profile?.status
? <StatusChip status={profile.status} labelMap={STATUS_LABELS} colorMap={STATUS_COLORS} />
@@ -718,12 +605,6 @@ function MitgliedDetail() {
: null
}
/>
<FieldRow
label="Austrittsdatum"
value={profile?.austrittsdatum
? new Date(profile.austrittsdatum).toLocaleDateString('de-AT')
: null}
/>
{editMode && isOwnProfile ? (
<Box>
<TextField
@@ -739,12 +620,6 @@ function MitgliedDetail() {
)}
<FieldRow label="Geburtsort" value={profile?.geburtsort ?? 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>
@@ -823,7 +698,10 @@ function MitgliedDetail() {
{/* Driving licenses */}
<Grid item xs={12} md={6}>
<Card>
<Card
sx={!editMode ? { cursor: 'pointer' } : undefined}
onClick={!editMode ? () => { setActiveTab(2); setQualSubTab(4); } : undefined}
>
<CardHeader
avatar={<DriveEtaIcon color="primary" />}
title="Führerscheinklassen"
@@ -854,18 +732,62 @@ function MitgliedDetail() {
</Card>
</Grid>
{/* Rank history */}
<Grid item xs={12}>
<Card>
<CardHeader
avatar={<HistoryIcon color="primary" />}
title="Dienstgrad-Verlauf"
/>
<CardContent>
<RankTimeline entries={member.dienstgrad_verlauf ?? []} />
</CardContent>
</Card>
</Grid>
{/* Atemschutz overview */}
{atemschutz && (
<Grid item xs={12} md={6}>
<Card
sx={{ cursor: 'pointer' }}
onClick={() => { setActiveTab(2); setQualSubTab(0); }}
>
<CardHeader
avatar={<SecurityIcon color="primary" />}
title="Atemschutz"
action={
<Chip
icon={atemschutz.einsatzbereit ? <CheckCircleIcon /> : <HighlightOffIcon />}
label={atemschutz.einsatzbereit ? 'Einsatzbereit' : 'Nicht einsatzbereit'}
color={atemschutz.einsatzbereit ? 'success' : 'error'}
size="small"
/>
}
/>
<CardContent>
<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>
</Card>
</Grid>
)}
{/* Remarks — Kommandant/Admin only */}
{canWrite && (
@@ -1044,7 +966,7 @@ function MitgliedDetail() {
<Divider sx={{ my: 1.5 }} />
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
G26.3 Untersuchung
Atemschutztauglichkeitsuntersuchung
</Typography>
<FieldRow
label="Datum"

View File

@@ -30,6 +30,7 @@ import { DataTable, StatusChip } from '../components/templates';
import { useAuth } from '../contexts/AuthContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { membersService } from '../services/members';
import { toGermanDate } from '../utils/dateInput';
import {
MemberListItem,
StatusEnum,
@@ -92,6 +93,8 @@ function Mitglieder() {
const [selectedDienstgrad, setSelectedDienstgrad] = useState<DienstgradEnum[]>([]);
const [page, setPage] = useState(0); // MUI uses 0-based
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
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() {
: <Typography variant="body2" color="text.secondary"></Typography>
},
{ 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={<PeopleIcon sx={{ fontSize: 40, mb: 1, opacity: 0.5 }} />}
searchEnabled={false}
paginationEnabled={false}
controlledSortKey={sortKey}
controlledSortDir={sortDir}
onSortChange={handleSortChange}
stickyHeader
/>
<TablePagination

View File

@@ -45,6 +45,9 @@ function buildParams(filters?: MemberFilters): URLSearchParams {
filters.status?.forEach((s) => 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;
}

View File

@@ -134,6 +134,8 @@ export interface MemberFilters {
dienstgrad?: DienstgradEnum[];
page?: number;
pageSize?: number;
sortBy?: string;
sortDir?: 'asc' | 'desc';
}
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,
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<void>
);
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<void>
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`);