feat(frontend): implement unified design system with 17 reusable template components, skeleton loading states, and golden-ratio-based layouts
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user