change dat format in member overview, sync exams to atemschutz tool, rework member detail page
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -166,6 +166,8 @@ export interface MemberFilters {
|
||||
dienstgrad?: DienstgradEnum[];
|
||||
page?: number; // 1-based
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortDir?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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.' },
|
||||
|
||||
@@ -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 }} />
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
647
frontend/src/pages/AtemschutzDetail.tsx
Normal file
647
frontend/src/pages/AtemschutzDetail.tsx
Normal 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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'>>;
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user