Files
dashboard/frontend/src/pages/Mitglieder.tsx

384 lines
14 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 { useState, useEffect, useCallback, useRef } from 'react';
import {
Container,
Typography,
Box,
TextField,
InputAdornment,
Chip,
Avatar,
Tooltip,
Alert,
FormControl,
InputLabel,
Select,
MenuItem,
OutlinedInput,
SelectChangeEvent,
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 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';
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 { hasPermission } = usePermissionContext();
return hasPermission('mitglieder:edit');
}
// ----------------------------------------------------------------
// 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 { user } = useAuth();
const canWrite = useCanWrite();
const { hasPermission } = usePermissionContext();
// --- redirect non-privileged users to their own profile ---
useEffect(() => {
if (!user) return;
if (!hasPermission('mitglieder:view_all')) {
navigate(`/mitglieder/${(user as any).id}`, { replace: true });
}
}, [user, navigate, hasPermission]);
// --- 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, setPageSize] = useState(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: pageSize === -1 ? 0 : pageSize, // -1 = MUI "Alle" sentinel → 0 = backend "no limit"
});
setMembers(items);
setTotal(t);
} catch (err) {
setError('Mitglieder konnten nicht geladen werden. Bitte versuchen Sie es erneut.');
} finally {
setLoading(false);
}
}, [debouncedSearch, selectedStatus, selectedDienstgrad, page, pageSize]);
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 (skip initial mount to avoid double-fetch)
const isInitialMount = useRef(true);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
fetchMembers();
}, [page, pageSize, 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' }}>
<TablePagination
component="div"
count={total}
page={page}
onPageChange={(_e, newPage) => setPage(newPage)}
rowsPerPage={pageSize}
rowsPerPageOptions={[25, 50, 100, { value: -1, label: 'Alle' }]}
onRowsPerPageChange={(e) => {
setPageSize(parseInt(e.target.value, 10));
setPage(0);
}}
labelRowsPerPage="Einträge pro Seite:"
labelDisplayedRows={({ from, to, count }) =>
`${from}${to} von ${count !== -1 ? count : `mehr als ${to}`}`
}
/>
<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"
count={total}
page={page}
onPageChange={(_e, newPage) => setPage(newPage)}
rowsPerPage={pageSize}
rowsPerPageOptions={[25, 50, 100, { value: -1, label: 'Alle' }]}
onRowsPerPageChange={(e) => {
setPageSize(parseInt(e.target.value, 10));
setPage(0);
}}
labelRowsPerPage="Einträge pro Seite:"
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">
<ChatAwareFab
aria-label="Neues Mitglied anlegen"
onClick={() => navigate('/mitglieder/neu')}
sx={{ zIndex: (theme) => theme.zIndex.speedDial }}
>
<AddIcon />
</ChatAwareFab>
</Tooltip>
)}
</DashboardLayout>
);
}
export default Mitglieder;