Files
dashboard/frontend/src/pages/MitgliedDetail.tsx

1204 lines
48 KiB
TypeScript

import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
Container,
Box,
Typography,
Card,
CardContent,
CardHeader,
Avatar,
Autocomplete,
Button,
Chip,
Tabs,
Tab,
Grid,
TextField,
MenuItem,
CircularProgress,
Alert,
Divider,
Tooltip,
IconButton,
Stack,
} from '@mui/material';
import {
Edit as EditIcon,
Save as SaveIcon,
Cancel as CancelIcon,
Person as PersonIcon,
Phone as PhoneIcon,
Security as SecurityIcon,
History as HistoryIcon,
DriveEta as DriveEtaIcon,
CheckCircle as CheckCircleIcon,
HighlightOff as HighlightOffIcon,
MilitaryTech as MilitaryTechIcon,
LocalHospital as LocalHospitalIcon,
School as SchoolIcon,
} from '@mui/icons-material';
import { useParams, useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useAuth } from '../contexts/AuthContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { membersService } from '../services/members';
import { atemschutzApi } from '../services/atemschutz';
import { toGermanDate, fromGermanDate } from '../utils/dateInput';
import {
MemberWithProfile,
StatusEnum,
DienstgradEnum,
FunktionEnum,
TshirtGroesseEnum,
DIENSTGRAD_VALUES,
STATUS_VALUES,
FUNKTION_VALUES,
TSHIRT_GROESSE_VALUES,
STATUS_LABELS,
STATUS_COLORS,
getMemberDisplayName,
formatPhone,
UpdateMemberProfileData,
} from '../types/member.types';
import type { Befoerderung, Untersuchung, Fahrgenehmigung, Ausbildung } from '../types/member.types';
import { StatusChip, TabPanel, PageHeader } from '../components/templates';
import type { AtemschutzUebersicht } from '../types/atemschutz.types';
import { UntersuchungErgebnisLabel } from '../types/atemschutz.types';
// ----------------------------------------------------------------
// Role helpers
// ----------------------------------------------------------------
function useCanWrite(): boolean {
const { hasPermission } = usePermissionContext();
return hasPermission('mitglieder:edit');
}
function useCurrentUserId(): string | undefined {
const { user } = useAuth();
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
// ----------------------------------------------------------------
function FieldRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<Box sx={{ display: 'flex', gap: 1, py: 0.75, borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 180, flexShrink: 0 }}>
{label}
</Typography>
<Typography variant="body2" sx={{ flex: 1 }}>
{value ?? '—'}
</Typography>
</Box>
);
}
// ----------------------------------------------------------------
// Validity chip — green / yellow (< 90 days) / red (expired)
// ----------------------------------------------------------------
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" />;
}
// ----------------------------------------------------------------
// Main component
// ----------------------------------------------------------------
function MitgliedDetail() {
const { userId } = useParams<{ userId: string }>();
const navigate = useNavigate();
const canWrite = useCanWrite();
const currentUserId = useCurrentUserId();
const isOwnProfile = currentUserId === userId;
const canEdit = canWrite || isOwnProfile;
// --- state ---
const [member, setMember] = useState<MemberWithProfile | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [editMode, setEditMode] = useState(false);
const [activeTab, setActiveTab] = useState(0);
// Atemschutz data for Qualifikationen tab
const [atemschutz, setAtemschutz] = useState<AtemschutzUebersicht | null>(null);
const [atemschutzLoading, setAtemschutzLoading] = useState(false);
// FDISK-synced sub-section data
const [befoerderungen, setBefoerderungen] = useState<Befoerderung[]>([]);
const [untersuchungen, setUntersuchungen] = useState<Untersuchung[]>([]);
const [fahrgenehmigungen, setFahrgenehmigungen] = useState<Fahrgenehmigung[]>([]);
const [ausbildungen, setAusbildungen] = useState<Ausbildung[]>([]);
// Edit form state — only the fields the user is allowed to change
const [formData, setFormData] = useState<UpdateMemberProfileData>({});
// Merge Führerscheinklassen from profile + Fahrgenehmigungen (FDISK)
const displayedFuehrerscheinklassen = useMemo(() => {
const fromFG = fahrgenehmigungen.map(f => f.klasse).filter(Boolean);
const fromProfile = member?.profile?.fuehrerscheinklassen ?? [];
return [...new Set([...fromProfile, ...fromFG])].sort();
}, [fahrgenehmigungen, member?.profile?.fuehrerscheinklassen]);
// ----------------------------------------------------------------
// Data loading
// ----------------------------------------------------------------
const loadMember = useCallback(async () => {
if (!userId) return;
setLoading(true);
setError(null);
try {
const data = await membersService.getMember(userId);
setMember(data);
} catch {
setError('Mitglied konnte nicht geladen werden.');
} finally {
setLoading(false);
}
}, [userId]);
useEffect(() => {
loadMember();
}, [loadMember]);
// Load atemschutz data alongside the member
useEffect(() => {
if (!userId) return;
setAtemschutzLoading(true);
atemschutzApi.getByUserId(userId)
.then((data) => setAtemschutz(data))
.catch(() => setAtemschutz(null))
.finally(() => setAtemschutzLoading(false));
}, [userId]);
// Load FDISK-synced sub-section data
useEffect(() => {
if (!userId) return;
membersService.getBefoerderungen(userId).then(setBefoerderungen).catch(() => setBefoerderungen([]));
membersService.getUntersuchungen(userId).then(setUntersuchungen).catch(() => setUntersuchungen([]));
membersService.getFahrgenehmigungen(userId).then(setFahrgenehmigungen).catch(() => setFahrgenehmigungen([]));
membersService.getAusbildungen(userId).then(setAusbildungen).catch(() => setAusbildungen([]));
}, [userId]);
// Populate form from current profile
useEffect(() => {
if (member?.profile) {
setFormData({
dienstgrad: member.profile.dienstgrad ?? undefined,
dienstgrad_seit: toGermanDate(member.profile.dienstgrad_seit) || undefined,
funktion: member.profile.funktion,
status: member.profile.status,
eintrittsdatum: toGermanDate(member.profile.eintrittsdatum) || undefined,
austrittsdatum: toGermanDate(member.profile.austrittsdatum) || undefined,
geburtsdatum: toGermanDate(member.profile.geburtsdatum) || undefined,
telefon_mobil: member.profile.telefon_mobil ?? undefined,
telefon_privat: member.profile.telefon_privat ?? undefined,
notfallkontakt_name: member.profile.notfallkontakt_name ?? undefined,
notfallkontakt_telefon: member.profile.notfallkontakt_telefon ?? undefined,
fuehrerscheinklassen: displayedFuehrerscheinklassen,
tshirt_groesse: member.profile.tshirt_groesse ?? undefined,
schuhgroesse: member.profile.schuhgroesse ?? undefined,
bemerkungen: member.profile.bemerkungen ?? undefined,
fdisk_standesbuch_nr: member.profile.fdisk_standesbuch_nr ?? undefined,
});
}
}, [member]);
// ----------------------------------------------------------------
// Save
// ----------------------------------------------------------------
const handleSave = async () => {
if (!userId) return;
setSaving(true);
setSaveError(null);
try {
let payload: UpdateMemberProfileData;
if (canWrite) {
// Admin / Kommandant: send all fields with date conversion
payload = {
...formData,
eintrittsdatum: formData.eintrittsdatum ? fromGermanDate(formData.eintrittsdatum) || undefined : undefined,
austrittsdatum: formData.austrittsdatum ? fromGermanDate(formData.austrittsdatum) || undefined : undefined,
geburtsdatum: formData.geburtsdatum ? fromGermanDate(formData.geburtsdatum) || undefined : undefined,
dienstgrad_seit: formData.dienstgrad_seit ? fromGermanDate(formData.dienstgrad_seit) || undefined : undefined,
};
} else {
// Regular member (own profile): only send fields allowed by SelfUpdateMemberProfileSchema
payload = {
telefon_mobil: formData.telefon_mobil,
telefon_privat: formData.telefon_privat,
notfallkontakt_name: formData.notfallkontakt_name,
notfallkontakt_telefon: formData.notfallkontakt_telefon,
tshirt_groesse: formData.tshirt_groesse,
schuhgroesse: formData.schuhgroesse,
bild_url: formData.bild_url,
fdisk_standesbuch_nr: formData.fdisk_standesbuch_nr,
};
}
const updated = await membersService.updateMember(userId, payload);
setMember(updated);
setEditMode(false);
} catch {
setSaveError('Speichern fehlgeschlagen. Bitte versuchen Sie es erneut.');
} finally {
setSaving(false);
}
};
const handleCancelEdit = () => {
setEditMode(false);
setSaveError(null);
// Reset form to current profile values
if (member?.profile) {
setFormData({
dienstgrad: member.profile.dienstgrad ?? undefined,
dienstgrad_seit: toGermanDate(member.profile.dienstgrad_seit) || undefined,
funktion: member.profile.funktion,
status: member.profile.status,
eintrittsdatum: toGermanDate(member.profile.eintrittsdatum) || undefined,
austrittsdatum: toGermanDate(member.profile.austrittsdatum) || undefined,
geburtsdatum: toGermanDate(member.profile.geburtsdatum) || undefined,
telefon_mobil: member.profile.telefon_mobil ?? undefined,
telefon_privat: member.profile.telefon_privat ?? undefined,
notfallkontakt_name: member.profile.notfallkontakt_name ?? undefined,
notfallkontakt_telefon: member.profile.notfallkontakt_telefon ?? undefined,
fuehrerscheinklassen: displayedFuehrerscheinklassen,
tshirt_groesse: member.profile.tshirt_groesse ?? undefined,
schuhgroesse: member.profile.schuhgroesse ?? undefined,
bemerkungen: member.profile.bemerkungen ?? undefined,
fdisk_standesbuch_nr: member.profile.fdisk_standesbuch_nr ?? undefined,
});
}
};
const handleFieldChange = (field: keyof UpdateMemberProfileData, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// ----------------------------------------------------------------
// Render helpers
// ----------------------------------------------------------------
if (loading) {
return (
<DashboardLayout>
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 8 }}>
<CircularProgress />
</Box>
</DashboardLayout>
);
}
if (error || !member) {
return (
<DashboardLayout>
<Container maxWidth="md">
<Alert severity="error" sx={{ mt: 4 }}>
{error ?? 'Mitglied nicht gefunden.'}
</Alert>
<Button sx={{ mt: 2 }} onClick={() => navigate('/mitglieder')}>
Zurück zur Liste
</Button>
</Container>
</DashboardLayout>
);
}
const displayName = getMemberDisplayName(member);
const profile = member.profile;
const initials = [member.given_name?.[0], member.family_name?.[0]]
.filter(Boolean)
.join('')
.toUpperCase() || member.email?.[0]?.toUpperCase() || '?';
return (
<DashboardLayout>
<Container maxWidth="lg">
<PageHeader
title={displayName}
backTo="/mitglieder"
/>
{/* Header card */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<Avatar
src={profile?.bild_url ?? member.profile_picture_url ?? undefined}
alt={displayName}
sx={{ width: 80, height: 80, fontSize: '1.75rem' }}
>
{initials}
</Avatar>
<Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="h5" fontWeight={600}>
{displayName}
</Typography>
{profile?.status && (
<StatusChip
status={profile.status}
labelMap={STATUS_LABELS}
colorMap={STATUS_COLORS}
/>
)}
</Box>
<Typography color="text.secondary" variant="body2" sx={{ mt: 0.5 }}>
{member.email}
</Typography>
{profile?.dienstgrad && (
<Typography variant="body2" sx={{ mt: 0.5 }}>
<strong>Dienstgrad:</strong> {profile.dienstgrad}
{profile.dienstgrad_seit
? ` (seit ${new Date(profile.dienstgrad_seit).toLocaleDateString('de-AT')})`
: ''}
</Typography>
)}
{profile && Array.isArray(profile.funktion) && profile.funktion.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, mt: 1, flexWrap: 'wrap' }}>
{profile.funktion.map((f) => (
<Chip key={f} label={f} size="small" color="secondary" variant="outlined" />
))}
</Box>
)}
</Box>
{/* Edit controls — only shown when profile exists */}
{canEdit && !!profile && (
<Box>
{editMode ? (
<Box sx={{ display: 'flex', gap: 1 }}>
<Tooltip title="Änderungen speichern">
<span>
<IconButton
color="primary"
onClick={handleSave}
disabled={saving}
aria-label="Speichern"
>
{saving ? <CircularProgress size={20} /> : <SaveIcon />}
</IconButton>
</span>
</Tooltip>
<Tooltip title="Abbrechen">
<IconButton
onClick={handleCancelEdit}
disabled={saving}
aria-label="Abbrechen"
>
<CancelIcon />
</IconButton>
</Tooltip>
</Box>
) : (
<Tooltip title="Bearbeiten">
<IconButton onClick={() => setEditMode(true)} aria-label="Bearbeiten">
<EditIcon />
</IconButton>
</Tooltip>
)}
</Box>
)}
</Box>
{!profile && (
<Alert
severity="info"
sx={{ mt: 2 }}
action={
canWrite ? (
<Button
color="inherit"
size="small"
disabled={saving}
onClick={async () => {
if (!userId) return;
setSaving(true);
setSaveError(null);
try {
await membersService.createMemberProfile(userId, { status: 'aktiv' });
await loadMember();
} catch {
setSaveError('Profil konnte nicht erstellt werden.');
} finally {
setSaving(false);
}
}}
>
{saving ? <CircularProgress size={16} /> : 'Profil erstellen'}
</Button>
) : undefined
}
>
Für dieses Mitglied wurde noch kein Profil angelegt.
{!canWrite && ' Wende dich an einen Administrator, um dein Profil anlegen zu lassen.'}
</Alert>
)}
{saveError && (
<Alert severity="error" sx={{ mt: 2 }} onClose={() => setSaveError(null)}>
{saveError}
</Alert>
)}
</CardContent>
</Card>
{/* Tabs */}
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs
value={activeTab}
onChange={(_e, v) => setActiveTab(v)}
aria-label="Mitglied Details"
variant="scrollable"
scrollButtons="auto"
>
<Tab label="Stammdaten" id="tab-0" aria-controls="tabpanel-0" />
<Tab label="Qualifikationen" id="tab-1" aria-controls="tabpanel-1" />
<Tab label="Einsätze" id="tab-2" aria-controls="tabpanel-2" />
</Tabs>
</Box>
{/* ---- Tab 0: Stammdaten ---- */}
<TabPanel value={activeTab} index={0}>
<Grid container spacing={3}>
{/* Personal data */}
<Grid item xs={12} md={6}>
<Card>
<CardHeader
avatar={<PersonIcon color="primary" />}
title="Persönliche Daten"
/>
<CardContent>
{editMode && canWrite ? (
<Stack spacing={2}>
<TextField
label="Dienstgrad"
select
fullWidth
size="small"
value={formData.dienstgrad ?? ''}
onChange={(e) => handleFieldChange('dienstgrad', e.target.value as DienstgradEnum || undefined)}
>
<MenuItem value=""></MenuItem>
{DIENSTGRAD_VALUES.map((dg) => (
<MenuItem key={dg} value={dg}>{dg}</MenuItem>
))}
</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
fullWidth
size="small"
value={formData.status ?? 'aktiv'}
onChange={(e) => handleFieldChange('status', e.target.value as StatusEnum)}
>
{STATUS_VALUES.map((s) => (
<MenuItem key={s} value={s}>{STATUS_LABELS[s]}</MenuItem>
))}
</TextField>
<TextField
label="Eintrittsdatum"
fullWidth
size="small"
placeholder="TT.MM.JJJJ"
value={formData.eintrittsdatum ?? ''}
onChange={(e) => handleFieldChange('eintrittsdatum', e.target.value || undefined)}
InputLabelProps={{ shrink: true }}
/>
<TextField
label="Geburtsdatum"
fullWidth
size="small"
placeholder="TT.MM.JJJJ"
value={formData.geburtsdatum ?? ''}
onChange={(e) => handleFieldChange('geburtsdatum', e.target.value || undefined)}
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
size="small"
value={formData.fdisk_standesbuch_nr ?? ''}
onChange={(e) => handleFieldChange('fdisk_standesbuch_nr', e.target.value || undefined)}
/>
<Autocomplete
multiple
options={[...FUNKTION_VALUES]}
value={formData.funktion ?? []}
onChange={(_e, newValue) => handleFieldChange('funktion', newValue as FunktionEnum[])}
renderInput={(params) => (
<TextField {...params} label="Funktion" size="small" />
)}
size="small"
/>
</Stack>
) : (
<>
<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} />
: null
} />
<FieldRow
label="Eintrittsdatum"
value={profile?.eintrittsdatum
? new Date(profile.eintrittsdatum).toLocaleDateString('de-AT')
: null}
/>
<FieldRow
label="Geburtsdatum"
value={
profile?.geburtsdatum
? new Date(profile.geburtsdatum).toLocaleDateString('de-AT')
: profile?._age
? `(${profile._age} Jahre)`
: null
}
/>
<FieldRow
label="Austrittsdatum"
value={profile?.austrittsdatum
? new Date(profile.austrittsdatum).toLocaleDateString('de-AT')
: null}
/>
{editMode && isOwnProfile ? (
<Box>
<TextField
label="Standesbuchnummer"
fullWidth
size="small"
value={formData.fdisk_standesbuch_nr ?? ''}
onChange={(e) => handleFieldChange('fdisk_standesbuch_nr', e.target.value || undefined)}
/>
</Box>
) : (
<FieldRow label="Standesbuchnummer" value={profile?.fdisk_standesbuch_nr ?? null} />
)}
<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>
</Card>
</Grid>
{/* Contact */}
<Grid item xs={12} md={6}>
<Card>
<CardHeader
avatar={<PhoneIcon color="primary" />}
title="Kontaktdaten"
/>
<CardContent>
{editMode ? (
<Stack spacing={2}>
<TextField
label="Mobil"
fullWidth
size="small"
value={formData.telefon_mobil ?? ''}
onChange={(e) => handleFieldChange('telefon_mobil', e.target.value || undefined)}
placeholder="+436641234567"
/>
<TextField
label="Privat"
fullWidth
size="small"
value={formData.telefon_privat ?? ''}
onChange={(e) => handleFieldChange('telefon_privat', e.target.value || undefined)}
placeholder="+4371234567"
/>
<Divider />
<Typography variant="caption" color="text.secondary">
Notfallkontakt
</Typography>
<TextField
label="Name"
fullWidth
size="small"
value={formData.notfallkontakt_name ?? ''}
onChange={(e) => handleFieldChange('notfallkontakt_name', e.target.value || undefined)}
/>
<TextField
label="Telefon"
fullWidth
size="small"
value={formData.notfallkontakt_telefon ?? ''}
onChange={(e) => handleFieldChange('notfallkontakt_telefon', e.target.value || undefined)}
placeholder="+436641234567"
/>
</Stack>
) : (
<>
<FieldRow label="Mobil" value={formatPhone(profile?.telefon_mobil)} />
<FieldRow label="Privat" value={formatPhone(profile?.telefon_privat)} />
<FieldRow
label="E-Mail"
value={
<a href={`mailto:${member.email}`} style={{ color: 'inherit' }}>
{member.email}
</a>
}
/>
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Notfallkontakt
</Typography>
<FieldRow label="Name" value={profile?.notfallkontakt_name ?? null} />
<FieldRow label="Telefon" value={formatPhone(profile?.notfallkontakt_telefon)} />
</>
)}
</CardContent>
</Card>
</Grid>
{/* Uniform sizing */}
<Grid item xs={12} md={6}>
<Card>
<CardHeader
avatar={<SecurityIcon color="primary" />}
title="Ausrüstung & Uniform"
/>
<CardContent>
{editMode ? (
<Stack spacing={2}>
<TextField
label="T-Shirt Größe"
select
fullWidth
size="small"
value={formData.tshirt_groesse ?? ''}
onChange={(e) => handleFieldChange('tshirt_groesse', e.target.value as TshirtGroesseEnum || undefined)}
>
<MenuItem value=""></MenuItem>
{TSHIRT_GROESSE_VALUES.map((g) => (
<MenuItem key={g} value={g}>{g}</MenuItem>
))}
</TextField>
<TextField
label="Schuhgröße"
fullWidth
size="small"
value={formData.schuhgroesse ?? ''}
onChange={(e) => handleFieldChange('schuhgroesse', e.target.value || undefined)}
placeholder="z.B. 43"
/>
</Stack>
) : (
<>
<FieldRow label="T-Shirt Größe" value={profile?.tshirt_groesse ?? null} />
<FieldRow label="Schuhgröße" value={profile?.schuhgroesse ?? null} />
</>
)}
</CardContent>
</Card>
</Grid>
{/* Driving licenses */}
<Grid item xs={12} md={6}>
<Card>
<CardHeader
avatar={<DriveEtaIcon color="primary" />}
title="Führerscheinklassen"
/>
<CardContent>
{editMode && canWrite ? (
<Autocomplete
multiple
freeSolo
options={['AM', 'A', 'B', 'BE', 'C', 'CE', 'C1', 'C1E', 'D', 'DE', 'D1', 'D1E', 'F']}
value={formData.fuehrerscheinklassen ?? []}
onChange={(_e, newValue) => handleFieldChange('fuehrerscheinklassen', newValue)}
renderInput={(params) => (
<TextField {...params} label="Führerscheinklassen" size="small" placeholder="Klasse hinzufügen…" />
)}
size="small"
/>
) : displayedFuehrerscheinklassen.length > 0 ? (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{displayedFuehrerscheinklassen.map((k) => (
<Chip key={k} label={k} size="small" variant="outlined" />
))}
</Box>
) : (
<Typography color="text.secondary" variant="body2"></Typography>
)}
</CardContent>
</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>
{/* Remarks — Kommandant/Admin only */}
{canWrite && (
<Grid item xs={12}>
<Card>
<CardHeader title="Interne Bemerkungen" />
<CardContent>
{editMode ? (
<TextField
fullWidth
multiline
rows={4}
label="Bemerkungen"
value={formData.bemerkungen ?? ''}
onChange={(e) => handleFieldChange('bemerkungen', e.target.value || undefined)}
/>
) : (
<Typography variant="body2" color={profile?.bemerkungen ? 'text.primary' : 'text.secondary'}>
{profile?.bemerkungen ?? 'Keine Bemerkungen eingetragen.'}
</Typography>
)}
</CardContent>
</Card>
</Grid>
)}
</Grid>
</TabPanel>
{/* ---- Tab 1: Qualifikationen ---- */}
<TabPanel value={activeTab} index={1}>
{atemschutzLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}>
<CircularProgress />
</Box>
) : atemschutz ? (
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Card>
<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
? `Ja${atemschutz.lehrgang_datum ? ` (${new Date(atemschutz.lehrgang_datum).toLocaleDateString('de-AT')})` : ''}`
: 'Nein'
}
/>
<Divider sx={{ my: 1.5 }} />
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
G26.3 Untersuchung
</Typography>
<FieldRow
label="Datum"
value={atemschutz.untersuchung_datum
? new Date(atemschutz.untersuchung_datum).toLocaleDateString('de-AT')
: null}
/>
<FieldRow
label="Gültig bis"
value={
<ValidityChip
date={atemschutz.untersuchung_gueltig_bis}
gueltig={atemschutz.untersuchung_gueltig}
tageRest={atemschutz.untersuchung_tage_rest}
/>
}
/>
<FieldRow
label="Ergebnis"
value={
atemschutz.untersuchung_ergebnis
? UntersuchungErgebnisLabel[atemschutz.untersuchung_ergebnis]
: null
}
/>
<Divider sx={{ my: 1.5 }} />
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
Leistungstest (Finnentest)
</Typography>
<FieldRow
label="Datum"
value={atemschutz.leistungstest_datum
? new Date(atemschutz.leistungstest_datum).toLocaleDateString('de-AT')
: null}
/>
<FieldRow
label="Gültig bis"
value={
<ValidityChip
date={atemschutz.leistungstest_gueltig_bis}
gueltig={atemschutz.leistungstest_gueltig}
tageRest={atemschutz.leistungstest_tage_rest}
/>
}
/>
<FieldRow
label="Bestanden"
value={
atemschutz.leistungstest_bestanden === true
? 'Ja'
: atemschutz.leistungstest_bestanden === false
? 'Nein'
: null
}
/>
{atemschutz.bemerkung && (
<>
<Divider sx={{ my: 1.5 }} />
<FieldRow label="Bemerkung" value={atemschutz.bemerkung} />
</>
)}
</CardContent>
</Card>
</Grid>
</Grid>
) : (
<Card>
<CardContent>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 6, gap: 2 }}>
<SecurityIcon sx={{ fontSize: 64, color: 'text.disabled' }} />
<Typography variant="h6" color="text.secondary">
Kein Atemschutz-Eintrag
</Typography>
<Typography variant="body2" color="text.secondary" textAlign="center" maxWidth={480}>
Für dieses Mitglied ist kein Atemschutz-Datensatz vorhanden.
</Typography>
{canWrite && (
<Button variant="outlined" onClick={() => navigate('/atemschutz')}>
Zum Atemschutzmodul
</Button>
)}
</Box>
</CardContent>
</Card>
)}
{/* FDISK-synced sub-sections */}
<Grid container spacing={3} sx={{ mt: 0 }}>
{/* Beförderungen */}
{befoerderungen.length > 0 && (
<Grid item xs={12} md={6}>
<Card>
<CardHeader
avatar={<MilitaryTechIcon color="primary" />}
title="Beförderungen"
/>
<CardContent>
{befoerderungen.map((b) => (
<FieldRow
key={b.id}
label={b.datum ? new Date(b.datum).toLocaleDateString('de-AT') : '—'}
value={b.dienstgrad}
/>
))}
</CardContent>
</Card>
</Grid>
)}
{/* Ausbildungen */}
{ausbildungen.length > 0 && (
<Grid item xs={12} md={6}>
<Card>
<CardHeader
avatar={<SchoolIcon color="primary" />}
title="Ausbildungen"
/>
<CardContent>
{ausbildungen.map((a) => (
<Box key={a.id} sx={{ py: 0.75, borderBottom: '1px solid', borderColor: 'divider' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<Typography variant="body2" fontWeight={500}>
{a.kursname}
</Typography>
<Typography variant="caption" color="text.secondary">
{a.kurs_datum ? new Date(a.kurs_datum).toLocaleDateString('de-AT') : '—'}
</Typography>
</Box>
{(a.ort || a.status !== 'abgeschlossen') && (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mt: 0.25 }}>
{a.ort && (
<Typography variant="body2" color="text.secondary">
{a.ort}
</Typography>
)}
{a.status !== 'abgeschlossen' && (
<Chip
label={a.status === 'in_bearbeitung' ? 'In Bearbeitung' : 'Abgelaufen'}
size="small"
color={a.status === 'abgelaufen' ? 'warning' : 'info'}
variant="outlined"
/>
)}
</Box>
)}
{a.ablaufdatum && (
<Typography variant="caption" color="text.secondary">
Gültig bis: {new Date(a.ablaufdatum).toLocaleDateString('de-AT')}
</Typography>
)}
</Box>
))}
</CardContent>
</Card>
</Grid>
)}
{/* Untersuchungen */}
{untersuchungen.length > 0 && (
<Grid item xs={12} md={6}>
<Card>
<CardHeader
avatar={<LocalHospitalIcon color="primary" />}
title="Untersuchungen"
/>
<CardContent>
{untersuchungen.map((u) => (
<Box key={u.id} sx={{ py: 0.75, borderBottom: '1px solid', borderColor: 'divider' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<Typography variant="body2" fontWeight={500}>
{u.art}
</Typography>
<Typography variant="caption" color="text.secondary">
{u.datum ? new Date(u.datum).toLocaleDateString('de-AT') : '—'}
</Typography>
</Box>
{u.ergebnis && (
<Typography variant="body2" color="text.secondary">
{u.ergebnis}
</Typography>
)}
{u.anmerkungen && (
<Typography variant="caption" color="text.secondary">
{u.anmerkungen}
</Typography>
)}
</Box>
))}
</CardContent>
</Card>
</Grid>
)}
{/* Fahrgenehmigungen */}
{fahrgenehmigungen.length > 0 && (
<Grid item xs={12} md={6}>
<Card>
<CardHeader
avatar={<DriveEtaIcon color="primary" />}
title="Gesetzliche Fahrgenehmigungen"
/>
<CardContent>
{fahrgenehmigungen.map((f) => (
<Box key={f.id} sx={{ py: 0.75, borderBottom: '1px solid', borderColor: 'divider' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<Typography variant="body2" fontWeight={500}>
{f.klasse}
</Typography>
<Typography variant="caption" color="text.secondary">
{f.ausstellungsdatum ? new Date(f.ausstellungsdatum).toLocaleDateString('de-AT') : '—'}
</Typography>
</Box>
{(f.behoerde || f.nummer) && (
<Typography variant="body2" color="text.secondary">
{[f.behoerde, f.nummer].filter(Boolean).join(' · ')}
</Typography>
)}
{f.gueltig_bis && (
<Typography variant="caption" color="text.secondary">
Gültig bis: {new Date(f.gueltig_bis).toLocaleDateString('de-AT')}
</Typography>
)}
</Box>
))}
</CardContent>
</Card>
</Grid>
)}
</Grid>
</TabPanel>
{/* ---- Tab 2: Einsätze (placeholder) ---- */}
<TabPanel value={activeTab} index={2}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 6, gap: 2 }}>
<PersonIcon sx={{ fontSize: 64, color: 'text.disabled' }} />
<Typography variant="h6" color="text.secondary">
Einsätze dieses Mitglieds
</Typography>
<Typography variant="body2" color="text.secondary" textAlign="center" maxWidth={480}>
Diese Funktion wird verfügbar sobald das Einsatz-Modul implementiert ist.
</Typography>
</Box>
</CardContent>
</Card>
</TabPanel>
</Container>
</DashboardLayout>
);
}
export default MitgliedDetail;