add features
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user