add features

This commit is contained in:
Matthias Hochmeister
2026-02-27 19:47:20 +01:00
parent 44e22a9fc6
commit c5e8337a69
11 changed files with 1554 additions and 194 deletions

View File

@@ -1,67 +1,436 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
Container,
Typography,
Card,
CardContent,
Box,
TextField,
InputAdornment,
Chip,
Avatar,
Fab,
Tooltip,
Alert,
CircularProgress,
FormControl,
InputLabel,
Select,
MenuItem,
OutlinedInput,
SelectChangeEvent,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
Paper,
} from '@mui/material';
import { People } from '@mui/icons-material';
import {
Search as SearchIcon,
Add as AddIcon,
People as PeopleIcon,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useAuth } from '../contexts/AuthContext';
import { membersService } from '../services/members';
import {
MemberListItem,
StatusEnum,
DienstgradEnum,
STATUS_VALUES,
DIENSTGRAD_VALUES,
STATUS_LABELS,
STATUS_COLORS,
getMemberDisplayName,
formatPhone,
} from '../types/member.types';
// ----------------------------------------------------------------
// Helper: determine whether the current user can write member data
// ----------------------------------------------------------------
function useCanWrite(): boolean {
const { user } = useAuth();
const groups: string[] = (user as any)?.groups ?? [];
return groups.includes('feuerwehr-admin') || groups.includes('feuerwehr-kommandant');
}
// ----------------------------------------------------------------
// Debounce hook
// ----------------------------------------------------------------
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState<T>(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
// ----------------------------------------------------------------
// Component
// ----------------------------------------------------------------
function Mitglieder() {
const navigate = useNavigate();
const canWrite = useCanWrite();
// --- data state ---
const [members, setMembers] = useState<MemberListItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// --- filter / pagination state ---
const [searchInput, setSearchInput] = useState('');
const debouncedSearch = useDebounce(searchInput, 300);
const [selectedStatus, setSelectedStatus] = useState<StatusEnum[]>([]);
const [selectedDienstgrad, setSelectedDienstgrad] = useState<DienstgradEnum[]>([]);
const [page, setPage] = useState(0); // MUI uses 0-based
const pageSize = 25;
// Track previous debounced search to reset page
const prevSearch = useRef(debouncedSearch);
// ----------------------------------------------------------------
// Data fetching
// ----------------------------------------------------------------
const fetchMembers = useCallback(async () => {
setLoading(true);
setError(null);
try {
const { items, total: t } = await membersService.getMembers({
search: debouncedSearch || undefined,
status: selectedStatus.length > 0 ? selectedStatus : undefined,
dienstgrad: selectedDienstgrad.length > 0 ? selectedDienstgrad : undefined,
page: page + 1, // convert to 1-based for API
pageSize,
});
setMembers(items);
setTotal(t);
} catch (err) {
setError('Mitglieder konnten nicht geladen werden. Bitte versuchen Sie es erneut.');
} finally {
setLoading(false);
}
}, [debouncedSearch, selectedStatus, selectedDienstgrad, page]);
useEffect(() => {
// Reset to page 0 when search changes
if (debouncedSearch !== prevSearch.current) {
prevSearch.current = debouncedSearch;
setPage(0);
return;
}
fetchMembers();
}, [fetchMembers, debouncedSearch]);
// Also fetch when page/filters change
useEffect(() => {
fetchMembers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, selectedStatus, selectedDienstgrad]);
// ----------------------------------------------------------------
// Event handlers
// ----------------------------------------------------------------
const handleStatusChange = (e: SelectChangeEvent<StatusEnum[]>) => {
setSelectedStatus(e.target.value as StatusEnum[]);
setPage(0);
};
const handleDienstgradChange = (e: SelectChangeEvent<DienstgradEnum[]>) => {
setSelectedDienstgrad(e.target.value as DienstgradEnum[]);
setPage(0);
};
const handleRowClick = (userId: string) => {
navigate(`/mitglieder/${userId}`);
};
const handleRemoveStatusChip = (status: StatusEnum) => {
setSelectedStatus((prev) => prev.filter((s) => s !== status));
setPage(0);
};
const handleRemoveDienstgradChip = (dg: DienstgradEnum) => {
setSelectedDienstgrad((prev) => prev.filter((d) => d !== dg));
setPage(0);
};
// ----------------------------------------------------------------
// Render
// ----------------------------------------------------------------
const activeFilters = selectedStatus.length + selectedDienstgrad.length;
return (
<DashboardLayout>
<Container maxWidth="lg">
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
Mitgliederverwaltung
</Typography>
<Container maxWidth="xl">
{/* Page heading */}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3, gap: 2, flexWrap: 'wrap' }}>
<Typography variant="h4" sx={{ flex: 1 }}>
Mitgliederverwaltung
</Typography>
<Typography variant="body2" color="text.secondary">
{loading ? '...' : `${total} Mitglieder`}
</Typography>
</Box>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<People color="primary" sx={{ fontSize: 48, mr: 2 }} />
<Box>
<Typography variant="h6">Mitglieder</Typography>
<Typography variant="body2" color="text.secondary">
Diese Funktion wird in Kürze verfügbar sein
</Typography>
</Box>
</Box>
<Box sx={{ mt: 3 }}>
<Typography variant="body1" color="text.secondary" paragraph>
Geplante Features:
</Typography>
<ul>
<li>
<Typography variant="body2" color="text.secondary">
Mitgliederliste mit Kontaktdaten
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Qualifikationen und Lehrgänge
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Anwesenheitsverwaltung
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Dienstpläne und -einteilungen
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Atemschutz-G26 Untersuchungen
</Typography>
</li>
</ul>
</Box>
</CardContent>
</Card>
{/* Toolbar: search + filters */}
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
<TextField
placeholder="Suche nach Name, E-Mail oder Mitgliedsnummer…"
size="small"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
sx={{ flex: '1 1 280px', minWidth: 220 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
}}
/>
{/* Status filter */}
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Status</InputLabel>
<Select<StatusEnum[]>
multiple
value={selectedStatus}
onChange={handleStatusChange}
input={<OutlinedInput label="Status" />}
renderValue={(selected) =>
selected.length === 0 ? 'Alle' : `${selected.length} gewählt`
}
>
{STATUS_VALUES.map((s) => (
<MenuItem key={s} value={s}>
{STATUS_LABELS[s]}
</MenuItem>
))}
</Select>
</FormControl>
{/* Dienstgrad filter */}
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Dienstgrad</InputLabel>
<Select<DienstgradEnum[]>
multiple
value={selectedDienstgrad}
onChange={handleDienstgradChange}
input={<OutlinedInput label="Dienstgrad" />}
renderValue={(selected) =>
selected.length === 0 ? 'Alle' : `${selected.length} gewählt`
}
>
{DIENSTGRAD_VALUES.map((dg) => (
<MenuItem key={dg} value={dg}>
{dg}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
{/* Active filter chips */}
{activeFilters > 0 && (
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}>
{selectedStatus.map((s) => (
<Chip
key={s}
label={STATUS_LABELS[s]}
size="small"
color={STATUS_COLORS[s]}
onDelete={() => handleRemoveStatusChip(s)}
/>
))}
{selectedDienstgrad.map((dg) => (
<Chip
key={dg}
label={dg}
size="small"
onDelete={() => handleRemoveDienstgradChip(dg)}
/>
))}
</Box>
)}
{/* Error state */}
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* 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>Mitgliedsnr.</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>
{/* Mitgliedsnr */}
<TableCell>
<Typography variant="body2">
{member.mitglieds_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' }}>
{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>
<TablePagination
component="div"
count={total}
page={page}
onPageChange={(_e, newPage) => setPage(newPage)}
rowsPerPage={pageSize}
rowsPerPageOptions={[pageSize]}
labelDisplayedRows={({ from, to, count }) =>
`${from}${to} von ${count !== -1 ? count : `mehr als ${to}`}`
}
/>
</Paper>
</Container>
{/* FAB — only visible to Kommandant/Admin */}
{canWrite && (
<Tooltip title="Neues Mitglied anlegen">
<Fab
color="primary"
aria-label="Neues Mitglied anlegen"
onClick={() => navigate('/mitglieder/neu')}
sx={{
position: 'fixed',
bottom: 32,
right: 32,
zIndex: (theme) => theme.zIndex.speedDial,
}}
>
<AddIcon />
</Fab>
</Tooltip>
)}
</DashboardLayout>
);
}