439 lines
15 KiB
TypeScript
439 lines
15 KiB
TypeScript
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;
|