Files
dashboard/frontend/src/pages/Mitglieder.tsx
Matthias Hochmeister c5e8337a69 add features
2026-02-27 19:47:20 +01:00

439 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
Container,
Typography,
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 {
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="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>
{/* 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>
);
}
export default Mitglieder;