add features

This commit is contained in:
Matthias Hochmeister
2026-02-27 19:50:14 +01:00
parent c5e8337a69
commit 620bacc6b5
46 changed files with 14095 additions and 1 deletions

View File

@@ -0,0 +1,792 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Container,
Box,
Typography,
Card,
CardContent,
CardHeader,
Avatar,
Button,
Chip,
Tabs,
Tab,
Grid,
TextField,
FormControl,
InputLabel,
Select,
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 {
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('feuerwehr-admin') || groups.includes('feuerwehr-kommandant');
}
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,
funktion: member.profile.funktion,
status: member.profile.status,
eintrittsdatum: member.profile.eintrittsdatum ?? undefined,
geburtsdatum: 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,
});
}
}, [member]);
// ----------------------------------------------------------------
// Save
// ----------------------------------------------------------------
const handleSave = async () => {
if (!userId) return;
setSaving(true);
setSaveError(null);
try {
const updated = await membersService.updateMember(userId, formData);
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({
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,
tshirt_groesse: member.profile.tshirt_groesse ?? undefined,
schuhgroesse: member.profile.schuhgroesse ?? 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 && 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 && (
<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 }}>
Für dieses Mitglied wurde noch kein Profil angelegt.
{canWrite && ' Ein Kommandant kann das Profil unter "Stammdaten" erstellen.'}
</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"
type="date"
fullWidth
size="small"
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"
type="date"
fullWidth
size="small"
value={formData.eintrittsdatum ?? ''}
onChange={(e) => handleFieldChange('eintrittsdatum', e.target.value || undefined)}
InputLabelProps={{ shrink: true }}
/>
<TextField
label="Geburtsdatum"
type="date"
fullWidth
size="small"
value={formData.geburtsdatum ?? ''}
onChange={(e) => handleFieldChange('geburtsdatum', e.target.value || undefined)}
InputLabelProps={{ shrink: true }}
/>
</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
}
/>
</>
)}
</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>
{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;