918 lines
35 KiB
TypeScript
918 lines
35 KiB
TypeScript
import React, { useState, useEffect, useCallback } 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,
|
|
Badge as BadgeIcon,
|
|
Security as SecurityIcon,
|
|
History as HistoryIcon,
|
|
DriveEta as DriveEtaIcon,
|
|
} from '@mui/icons-material';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import { membersService } from '../services/members';
|
|
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';
|
|
|
|
// ----------------------------------------------------------------
|
|
// Role helpers
|
|
// ----------------------------------------------------------------
|
|
function useCanWrite(): boolean {
|
|
const { user } = useAuth();
|
|
const groups: string[] = (user as any)?.groups ?? [];
|
|
return groups.includes('dashboard_admin') || groups.includes('dashboard_kommando');
|
|
}
|
|
|
|
function useCurrentUserId(): string | undefined {
|
|
const { user } = useAuth();
|
|
return (user as any)?.id;
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Tab panel helper
|
|
// ----------------------------------------------------------------
|
|
interface TabPanelProps {
|
|
children?: React.ReactNode;
|
|
value: number;
|
|
index: number;
|
|
}
|
|
|
|
function TabPanel({ children, value, index }: TabPanelProps) {
|
|
return (
|
|
<div role="tabpanel" hidden={value !== index} aria-labelledby={`tab-${index}`}>
|
|
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// 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>
|
|
);
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// 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);
|
|
|
|
// Edit form state — only the fields the user is allowed to change
|
|
const [formData, setFormData] = useState<UpdateMemberProfileData>({});
|
|
|
|
// ----------------------------------------------------------------
|
|
// 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]);
|
|
|
|
// Populate form from current profile
|
|
useEffect(() => {
|
|
if (member?.profile) {
|
|
setFormData({
|
|
mitglieds_nr: member.profile.mitglieds_nr ?? undefined,
|
|
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: member.profile.fuehrerscheinklassen,
|
|
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({
|
|
mitglieds_nr: member.profile.mitglieds_nr ?? undefined,
|
|
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: member.profile.fuehrerscheinklassen,
|
|
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">
|
|
{/* Back button */}
|
|
<Button
|
|
variant="text"
|
|
onClick={() => navigate('/mitglieder')}
|
|
sx={{ mb: 2 }}
|
|
>
|
|
← Mitgliederliste
|
|
</Button>
|
|
|
|
{/* 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?.mitglieds_nr && (
|
|
<Chip
|
|
icon={<BadgeIcon />}
|
|
label={`Nr. ${profile.mitglieds_nr}`}
|
|
size="small"
|
|
variant="outlined"
|
|
/>
|
|
)}
|
|
{profile?.status && (
|
|
<Chip
|
|
label={STATUS_LABELS[profile.status]}
|
|
size="small"
|
|
color={STATUS_COLORS[profile.status]}
|
|
/>
|
|
)}
|
|
</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 */}
|
|
{canEdit && (canWrite || !!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"
|
|
>
|
|
<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="Mitgliedsnummer"
|
|
fullWidth
|
|
size="small"
|
|
value={formData.mitglieds_nr ?? ''}
|
|
onChange={(e) => handleFieldChange('mitglieds_nr', e.target.value || undefined)}
|
|
/>
|
|
|
|
<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
|
|
? <Chip label={STATUS_LABELS[profile.status]} size="small" color={STATUS_COLORS[profile.status]} />
|
|
: null
|
|
} />
|
|
<FieldRow label="Mitgliedsnummer" value={profile?.mitglieds_nr ?? 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} />
|
|
)}
|
|
</>
|
|
)}
|
|
</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"
|
|
/>
|
|
) : profile?.fuehrerscheinklassen && profile.fuehrerscheinklassen.length > 0 ? (
|
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
|
{profile.fuehrerscheinklassen.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 (placeholder) ---- */}
|
|
<TabPanel value={activeTab} index={1}>
|
|
<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">
|
|
Qualifikationen & Lehrgänge
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary" textAlign="center" maxWidth={480}>
|
|
Diese Funktion wird in einer zukünftigen Version verfügbar sein.
|
|
Geplant: Atemschutz, G26-Untersuchungen, Absolvierte Kurse, Gültigkeitsdaten.
|
|
</Typography>
|
|
</Box>
|
|
</CardContent>
|
|
</Card>
|
|
</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;
|