feat(frontend): implement unified design system with 17 reusable template components, skeleton loading states, and golden-ratio-based layouts

This commit is contained in:
Matthias Hochmeister
2026-04-13 10:43:27 +02:00
parent 5acfd7cc4f
commit 43ce1f930c
69 changed files with 3289 additions and 3115 deletions

View File

@@ -9,19 +9,12 @@ import {
Avatar,
Tooltip,
Alert,
CircularProgress,
FormControl,
InputLabel,
Select,
MenuItem,
OutlinedInput,
SelectChangeEvent,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
Paper,
} from '@mui/material';
@@ -33,6 +26,7 @@ import {
import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { DataTable, StatusChip } from '../components/templates';
import { useAuth } from '../contexts/AuthContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { membersService } from '../services/members';
@@ -281,138 +275,60 @@ function Mitglieder() {
{/* Table */}
<Paper sx={{ width: '100%', overflow: 'hidden' }}>
<TableContainer>
<Table stickyHeader size="small" aria-label="Mitgliederliste">
<TableHead>
<TableRow>
<TableCell sx={{ width: 56 }}>Foto</TableCell>
<TableCell>Name</TableCell>
<TableCell>Stundenbuchnr.</TableCell>
<TableCell>Dienstgrad</TableCell>
<TableCell>Funktion</TableCell>
<TableCell>Status</TableCell>
<TableCell>Eintrittsdatum</TableCell>
<TableCell>Telefon</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 6 }}>
<CircularProgress size={32} />
</TableCell>
</TableRow>
) : members.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 8 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
<PeopleIcon sx={{ fontSize: 48, color: 'text.disabled' }} />
<Typography color="text.secondary">
Keine Mitglieder gefunden.
</Typography>
</Box>
</TableCell>
</TableRow>
) : (
members.map((member) => {
const displayName = getMemberDisplayName(member);
const initials = [member.given_name?.[0], member.family_name?.[0]]
.filter(Boolean)
.join('')
.toUpperCase() || member.email[0].toUpperCase();
return (
<TableRow
key={member.id}
hover
onClick={() => handleRowClick(member.id)}
sx={{ cursor: 'pointer' }}
aria-label={`Mitglied ${displayName} öffnen`}
>
{/* Avatar */}
<TableCell>
<Avatar
src={member.profile_picture_url ?? undefined}
alt={displayName}
sx={{ width: 36, height: 36, fontSize: '0.875rem' }}
>
{initials}
</Avatar>
</TableCell>
{/* Name + email */}
<TableCell>
<Typography variant="body2" fontWeight={500}>
{displayName}
</Typography>
<Typography variant="caption" color="text.secondary">
{member.email}
</Typography>
</TableCell>
{/* Stundenbuchnr */}
<TableCell>
<Typography variant="body2">
{member.fdisk_standesbuch_nr ?? '—'}
</Typography>
</TableCell>
{/* Dienstgrad */}
<TableCell>
{member.dienstgrad ? (
<Chip label={member.dienstgrad} size="small" variant="outlined" />
) : (
<Typography variant="body2" color="text.secondary"></Typography>
)}
</TableCell>
{/* Funktion(en) */}
<TableCell>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{Array.isArray(member.funktion) && member.funktion.length > 0
? member.funktion.map((f) => (
<Chip key={f} label={f} size="small" variant="outlined" color="secondary" />
))
: <Typography variant="body2" color="text.secondary"></Typography>
}
</Box>
</TableCell>
{/* Status */}
<TableCell>
{member.status ? (
<Chip
label={STATUS_LABELS[member.status]}
size="small"
color={STATUS_COLORS[member.status]}
/>
) : (
<Typography variant="body2" color="text.secondary"></Typography>
)}
</TableCell>
{/* Eintrittsdatum */}
<TableCell>
<Typography variant="body2">
{member.eintrittsdatum
? new Date(member.eintrittsdatum).toLocaleDateString('de-AT')
: '—'}
</Typography>
</TableCell>
{/* Telefon */}
<TableCell>
<Typography variant="body2">
{formatPhone(member.telefon_mobil)}
</Typography>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
<DataTable<MemberListItem>
columns={[
{ key: 'profile_picture_url', label: 'Foto', width: 56, sortable: false, searchable: false, render: (member) => {
const displayName = getMemberDisplayName(member);
const initials = [member.given_name?.[0], member.family_name?.[0]]
.filter(Boolean).join('').toUpperCase() || member.email[0].toUpperCase();
return (
<Avatar src={member.profile_picture_url ?? undefined} alt={displayName} sx={{ width: 36, height: 36, fontSize: '0.875rem' }}>
{initials}
</Avatar>
);
}},
{ key: 'family_name', label: 'Name', render: (member) => {
const displayName = getMemberDisplayName(member);
return (
<Box>
<Typography variant="body2" fontWeight={500}>{displayName}</Typography>
<Typography variant="caption" color="text.secondary">{member.email}</Typography>
</Box>
);
}},
{ key: 'fdisk_standesbuch_nr', label: 'Stundenbuchnr.', render: (member) => member.fdisk_standesbuch_nr ?? '—' },
{ key: 'dienstgrad', label: 'Dienstgrad', render: (member) => member.dienstgrad
? <Chip label={member.dienstgrad} size="small" variant="outlined" />
: <Typography variant="body2" color="text.secondary"></Typography>
},
{ key: 'funktion', label: 'Funktion', sortable: false, render: (member) => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{Array.isArray(member.funktion) && member.funktion.length > 0
? member.funktion.map((f) => <Chip key={f} label={f} size="small" variant="outlined" color="secondary" />)
: <Typography variant="body2" color="text.secondary"></Typography>
}
</Box>
)},
{ key: 'status', label: 'Status', render: (member) => member.status
? <StatusChip status={member.status} labelMap={STATUS_LABELS} colorMap={STATUS_COLORS} />
: <Typography variant="body2" color="text.secondary"></Typography>
},
{ key: 'eintrittsdatum', label: 'Eintrittsdatum', render: (member) => member.eintrittsdatum
? new Date(member.eintrittsdatum).toLocaleDateString('de-AT')
: '—'
},
{ key: 'telefon_mobil', label: 'Telefon', render: (member) => formatPhone(member.telefon_mobil) },
]}
data={members}
rowKey={(member) => member.id}
onRowClick={(member) => handleRowClick(member.id)}
isLoading={loading}
emptyMessage="Keine Mitglieder gefunden."
emptyIcon={<PeopleIcon sx={{ fontSize: 40, mb: 1, opacity: 0.5 }} />}
searchEnabled={false}
paginationEnabled={false}
stickyHeader
/>
<TablePagination
component="div"