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

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