fix(sync): add Sachbearbeiter to dienstgrad constraint; add catalog browser dialog for external order position

This commit is contained in:
Matthias Hochmeister
2026-04-15 18:05:39 +02:00
parent 9586822a32
commit 67fd0878ce
5 changed files with 486 additions and 32 deletions

View File

@@ -61,7 +61,7 @@ class MemberController {
status: normalizeArray(statusParam) as any, status: normalizeArray(statusParam) as any,
dienstgrad: normalizeArray(dienstgradParam) as any, dienstgrad: normalizeArray(dienstgradParam) as any,
page: page ? parseInt(page, 10) || 1 : 1, page: page ? parseInt(page, 10) || 1 : 1,
pageSize: pageSize ? Math.min(parseInt(pageSize, 10) || 25, 100) : 25, pageSize: pageSize ? (parseInt(pageSize, 10) === 0 ? 0 : Math.min(parseInt(pageSize, 10) || 25, 100)) : 25,
}); });
res.status(200).json({ res.status(200).json({

View File

@@ -0,0 +1,57 @@
-- Migration 088: Add 'Sachbearbeiter' to mitglieder_profile dienstgrad CHECK constraint.
-- 'SB' is a valid FDISK Dienstgrad abbreviation that was missing from the allowed list,
-- causing the FDISK sync to fail when a member's current rank is Sachbearbeiter.
ALTER TABLE mitglieder_profile
DROP CONSTRAINT IF EXISTS mitglieder_profile_dienstgrad_check;
ALTER TABLE mitglieder_profile
ADD CONSTRAINT mitglieder_profile_dienstgrad_check
CHECK (dienstgrad IS NULL OR dienstgrad IN (
-- Standard Dienstgrade
'Feuerwehranwärter',
'Jugendfeuerwehrmann',
'Probefeuerwehrmann',
'Feuerwehrmann',
'Feuerwehrfrau',
'Oberfeuerwehrmann',
'Oberfeuerwehrfrau',
'Hauptfeuerwehrmann',
'Hauptfeuerwehrfrau',
'Löschmeister',
'Oberlöschmeister',
'Hauptlöschmeister',
'Brandmeister',
'Oberbrandmeister',
'Hauptbrandmeister',
'Brandinspektor',
'Oberbrandinspektor',
'Brandoberinspektor',
'Brandamtmann',
'Verwaltungsmeister',
'Oberverwaltungsmeister',
'Hauptverwaltungsmeister',
'Verwalter',
'Sachbearbeiter',
-- Ehrendienstgrade
'Ehren-Feuerwehrmann',
'Ehren-Feuerwehrfrau',
'Ehren-Oberfeuerwehrmann',
'Ehren-Oberfeuerwehrfrau',
'Ehren-Hauptfeuerwehrmann',
'Ehren-Hauptfeuerwehrfrau',
'Ehren-Löschmeister',
'Ehren-Oberlöschmeister',
'Ehren-Hauptlöschmeister',
'Ehren-Brandmeister',
'Ehren-Oberbrandmeister',
'Ehren-Hauptbrandmeister',
'Ehren-Brandinspektor',
'Ehren-Oberbrandinspektor',
'Ehren-Brandoberinspektor',
'Ehren-Brandamtmann',
'Ehren-Verwaltungsmeister',
'Ehren-Oberverwaltungsmeister',
'Ehren-Hauptverwaltungsmeister',
'Ehren-Verwalter'
));

View File

@@ -163,31 +163,57 @@ class MemberService {
} }
const whereClause = `WHERE ${conditions.join(' AND ')}`; const whereClause = `WHERE ${conditions.join(' AND ')}`;
const offset = (page - 1) * pageSize; const fetchAll = pageSize === 0;
const dataQuery = ` let dataQuery: string;
SELECT if (fetchAll) {
u.id, dataQuery = `
u.name, SELECT
u.given_name, u.id,
u.family_name, u.name,
u.email, u.given_name,
u.profile_picture_url, u.family_name,
u.is_active, u.email,
mp.id AS profile_id, u.profile_picture_url,
mp.fdisk_standesbuch_nr, u.is_active,
mp.dienstgrad, mp.id AS profile_id,
mp.funktion, mp.fdisk_standesbuch_nr,
mp.status, mp.dienstgrad,
mp.eintrittsdatum, mp.funktion,
mp.telefon_mobil mp.status,
FROM users u mp.eintrittsdatum,
LEFT JOIN mitglieder_profile mp ON mp.user_id = u.id mp.telefon_mobil
${whereClause} FROM users u
ORDER BY u.family_name ASC NULLS LAST, u.given_name ASC NULLS LAST LEFT JOIN mitglieder_profile mp ON mp.user_id = u.id
LIMIT $${paramIdx} OFFSET $${paramIdx + 1} ${whereClause}
`; ORDER BY u.family_name ASC NULLS LAST, u.given_name ASC NULLS LAST
values.push(pageSize, offset); `;
} else {
const offset = (page - 1) * pageSize;
dataQuery = `
SELECT
u.id,
u.name,
u.given_name,
u.family_name,
u.email,
u.profile_picture_url,
u.is_active,
mp.id AS profile_id,
mp.fdisk_standesbuch_nr,
mp.dienstgrad,
mp.funktion,
mp.status,
mp.eintrittsdatum,
mp.telefon_mobil
FROM users u
LEFT JOIN mitglieder_profile mp ON mp.user_id = u.id
${whereClause}
ORDER BY u.family_name ASC NULLS LAST, u.given_name ASC NULLS LAST
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
`;
values.push(pageSize, offset);
}
const countQuery = ` const countQuery = `
SELECT COUNT(*)::INTEGER AS total SELECT COUNT(*)::INTEGER AS total
@@ -196,9 +222,11 @@ class MemberService {
${whereClause} ${whereClause}
`; `;
const countValues = fetchAll ? values : values.slice(0, values.length - 2);
const [dataResult, countResult] = await Promise.all([ const [dataResult, countResult] = await Promise.all([
pool.query(dataQuery, values), pool.query(dataQuery, values),
pool.query(countQuery, values.slice(0, values.length - 2)), // exclude LIMIT/OFFSET pool.query(countQuery, countValues),
]); ]);
const items: MemberListItem[] = dataResult.rows.map((row) => ({ const items: MemberListItem[] = dataResult.rows.map((row) => ({

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef, useEffect, useMemo } from 'react';
import { import {
Alert, Alert,
Box, Box,
@@ -12,6 +12,7 @@ import {
TableContainer, TableContainer,
TableHead, TableHead,
TableRow, TableRow,
TableSortLabel,
TextField, TextField,
IconButton, IconButton,
Grid, Grid,
@@ -26,6 +27,10 @@ import {
AccordionDetails, AccordionDetails,
Autocomplete, Autocomplete,
Tooltip, Tooltip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material'; } from '@mui/material';
import { import {
Add as AddIcon, Add as AddIcon,
@@ -40,7 +45,7 @@ import {
Save as SaveIcon, Save as SaveIcon,
PictureAsPdf as PdfIcon, PictureAsPdf as PdfIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import GermanDateField from '../components/shared/GermanDateField'; import GermanDateField from '../components/shared/GermanDateField';
@@ -52,7 +57,7 @@ import { configApi } from '../services/config';
import { addPdfHeader, addPdfFooter } from '../utils/pdfExport'; import { addPdfHeader, addPdfFooter } from '../utils/pdfExport';
import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types'; import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types';
import type { BestellungStatus, BestellpositionFormData, ErinnerungFormData } from '../types/bestellung.types'; import type { BestellungStatus, BestellpositionFormData, ErinnerungFormData } from '../types/bestellung.types';
import type { AusruestungArtikel, AusruestungEigenschaft } from '../types/ausruestungsanfrage.types'; import type { AusruestungArtikel, AusruestungEigenschaft, AusruestungKategorie } from '../types/ausruestungsanfrage.types';
import { ConfirmDialog, StatusChip, PageHeader } from '../components/templates'; import { ConfirmDialog, StatusChip, PageHeader } from '../components/templates';
// ── Helpers ── // ── Helpers ──
@@ -111,6 +116,342 @@ const STATUS_TRANSITIONS: Record<BestellungStatus, BestellungStatus[]> = {
// Empty line item form // Empty line item form
const emptyItem: BestellpositionFormData = { bezeichnung: '', artikelnummer: '', menge: 1, einheit: 'Stk', einzelpreis: undefined }; const emptyItem: BestellpositionFormData = { bezeichnung: '', artikelnummer: '', menge: 1, einheit: 'Stk', einzelpreis: undefined };
// ══════════════════════════════════════════════════════════════════════════════
// KatalogAddDialog — browse catalog and add items to order
// ══════════════════════════════════════════════════════════════════════════════
interface KatalogAddDialogProps {
open: boolean;
onClose: () => void;
onAddItem: (data: BestellpositionFormData) => void;
isPending: boolean;
}
function KatalogAddDialog({ open, onClose, onAddItem, isPending }: KatalogAddDialogProps) {
const [search, setSearch] = useState('');
const [filterKategorie, setFilterKategorie] = useState<number | ''>('');
const [expandedId, setExpandedId] = useState<number | null>(null);
const [itemConfig, setItemConfig] = useState({ menge: 1, einheit: 'Stk', einzelpreis: '', artikelnummer: '' });
const [eigenschaftValues, setEigenschaftValues] = useState<Record<number, string>>({});
const [sortField, setSortField] = useState<'bezeichnung' | 'kategorie'>('bezeichnung');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setSearch('');
setFilterKategorie('');
setExpandedId(null);
setEigenschaftValues({});
setSortField('bezeichnung');
setSortDir('asc');
}
}, [open]);
const { data: kategorien = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'kategorien'],
queryFn: () => ausruestungsanfrageApi.getKategorien(),
enabled: open,
});
const { data: items = [], isFetching } = useQuery({
queryKey: ['ausruestungsanfrage', 'items', filterKategorie, search],
queryFn: () => ausruestungsanfrageApi.getItems({
...(filterKategorie ? { kategorie_id: filterKategorie as number } : {}),
...(search.trim() ? { search: search.trim() } : {}),
aktiv: true,
}),
placeholderData: keepPreviousData,
enabled: open,
});
const { data: eigenschaften = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'eigenschaften', expandedId],
queryFn: () => ausruestungsanfrageApi.getArtikelEigenschaften(expandedId!),
enabled: open && !!expandedId,
});
const kategorieOptions = useMemo(() => {
const map = new Map(kategorien.map((k: AusruestungKategorie) => [k.id, k]));
return kategorien.map((k: AusruestungKategorie) => {
if (k.parent_id) {
const parent = map.get(k.parent_id);
return { id: k.id, name: parent ? `${parent.name} > ${k.name}` : k.name };
}
return { id: k.id, name: k.name };
});
}, [kategorien]);
const sortedItems = useMemo(() => {
const sorted = [...items];
sorted.sort((a, b) => {
let aVal: string, bVal: string;
if (sortField === 'bezeichnung') {
aVal = a.bezeichnung.toLowerCase();
bVal = b.bezeichnung.toLowerCase();
} else {
aVal = (kategorieOptions.find(k => k.id === a.kategorie_id)?.name || a.kategorie_name || '').toLowerCase();
bVal = (kategorieOptions.find(k => k.id === b.kategorie_id)?.name || b.kategorie_name || '').toLowerCase();
}
if (aVal < bVal) return sortDir === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDir === 'asc' ? 1 : -1;
return 0;
});
return sorted;
}, [items, sortField, sortDir, kategorieOptions]);
const handleToggleExpand = (item: AusruestungArtikel) => {
if (expandedId === item.id) {
setExpandedId(null);
setEigenschaftValues({});
} else {
setExpandedId(item.id);
setEigenschaftValues({});
setItemConfig({
menge: 1,
einheit: 'Stk',
einzelpreis: item.geschaetzter_preis != null ? String(item.geschaetzter_preis) : '',
artikelnummer: '',
});
}
};
const handleSort = (field: 'bezeichnung' | 'kategorie') => {
if (sortField === field) {
setSortDir(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDir('asc');
}
};
const handleAdd = (item: AusruestungArtikel) => {
const charSpecs = Object.entries(eigenschaftValues)
.filter(([, v]) => v.trim())
.map(([eid, v]) => {
const e = eigenschaften.find(e => e.id === Number(eid));
return e ? `${e.name}: ${v}` : v;
});
onAddItem({
bezeichnung: item.bezeichnung,
artikel_id: item.id,
artikelnummer: itemConfig.artikelnummer || undefined,
menge: Number(itemConfig.menge) || 1,
einheit: itemConfig.einheit || 'Stk',
einzelpreis: itemConfig.einzelpreis ? Number(itemConfig.einzelpreis) : undefined,
spezifikationen: charSpecs.length > 0 ? charSpecs : undefined,
});
setExpandedId(null);
setEigenschaftValues({});
};
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth
PaperProps={{ sx: { height: '80vh' } }}>
<DialogTitle>Artikel aus Katalog hinzufügen</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 0, p: 0 }}>
{/* Filters */}
<Box sx={{ display: 'flex', gap: 2, p: 2, pb: 1.5, flexWrap: 'wrap', alignItems: 'center', borderBottom: 1, borderColor: 'divider' }}>
<TextField
size="small"
label="Suche"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Artikel suchen..."
sx={{ minWidth: 200 }}
/>
<TextField
select
size="small"
label="Kategorie"
value={filterKategorie}
onChange={e => setFilterKategorie(e.target.value as number | '')}
sx={{ minWidth: 180 }}
>
<MenuItem value="">Alle Kategorien</MenuItem>
{kategorieOptions.map(k => (
<MenuItem key={k.id} value={k.id}>{k.name}</MenuItem>
))}
</TextField>
{isFetching && <Typography variant="caption" color="text.secondary">Lade...</Typography>}
</Box>
{/* Table */}
<Box sx={{ flexGrow: 1, overflowY: 'auto' }}>
{items.length === 0 && !isFetching ? (
<Box sx={{ p: 3 }}>
<Typography color="text.secondary">Keine Artikel gefunden.</Typography>
</Box>
) : (
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>
<TableSortLabel
active={sortField === 'bezeichnung'}
direction={sortField === 'bezeichnung' ? sortDir : 'asc'}
onClick={() => handleSort('bezeichnung')}
>
Bezeichnung
</TableSortLabel>
</TableCell>
<TableCell>
<TableSortLabel
active={sortField === 'kategorie'}
direction={sortField === 'kategorie' ? sortDir : 'asc'}
onClick={() => handleSort('kategorie')}
>
Kategorie
</TableSortLabel>
</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell align="right">Richtpreis</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{sortedItems.map(item => (
<React.Fragment key={item.id}>
<TableRow
hover
selected={expandedId === item.id}
sx={{ cursor: 'pointer' }}
onClick={() => handleToggleExpand(item)}
>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{item.bezeichnung}
{(item.eigenschaften_count ?? 0) > 0 && (
<Chip label={`${item.eigenschaften_count} Eig.`} size="small" variant="outlined" />
)}
</Box>
</TableCell>
<TableCell>
{kategorieOptions.find(k => k.id === item.kategorie_id)?.name || item.kategorie_name || ''}
</TableCell>
<TableCell sx={{ maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.beschreibung || ''}
</TableCell>
<TableCell align="right">
{item.geschaetzter_preis != null
? new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(item.geschaetzter_preis)
: ''}
</TableCell>
<TableCell align="right">
<Button size="small" variant={expandedId === item.id ? 'contained' : 'outlined'}
onClick={e => { e.stopPropagation(); handleToggleExpand(item); }}>
{expandedId === item.id ? 'Abbrechen' : 'Auswählen'}
</Button>
</TableCell>
</TableRow>
{/* Expanded configuration row */}
{expandedId === item.id && (
<TableRow>
<TableCell colSpan={5} sx={{ p: 0 }}>
<Box sx={{ p: 2, bgcolor: 'action.hover', borderLeft: 4, borderColor: 'primary.main' }}>
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>
{item.bezeichnung} Konfigurieren
</Typography>
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'flex-start' }}>
<TextField
size="small"
label="Menge"
type="number"
value={itemConfig.menge}
onChange={e => setItemConfig(c => ({ ...c, menge: Math.max(1, Number(e.target.value)) }))}
inputProps={{ min: 1 }}
sx={{ width: 90 }}
/>
<TextField
size="small"
label="Einheit"
value={itemConfig.einheit}
onChange={e => setItemConfig(c => ({ ...c, einheit: e.target.value }))}
sx={{ width: 90 }}
/>
<TextField
size="small"
label="Einzelpreis"
type="number"
value={itemConfig.einzelpreis}
onChange={e => setItemConfig(c => ({ ...c, einzelpreis: e.target.value }))}
placeholder="EUR"
sx={{ width: 120 }}
/>
<TextField
size="small"
label="Artikelnr. (optional)"
value={itemConfig.artikelnummer}
onChange={e => setItemConfig(c => ({ ...c, artikelnummer: e.target.value }))}
sx={{ width: 160 }}
/>
</Box>
{/* Eigenschaften */}
{eigenschaften.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 1.5 }}>
{eigenschaften.map(e =>
e.typ === 'options' && e.optionen?.length ? (
<TextField
key={e.id}
select
size="small"
label={e.name}
required={e.pflicht}
value={eigenschaftValues[e.id] || ''}
onChange={ev => setEigenschaftValues(prev => ({ ...prev, [e.id]: ev.target.value }))}
sx={{ minWidth: 140 }}
>
<MenuItem value=""></MenuItem>
{e.optionen.map(opt => (
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
))}
</TextField>
) : (
<TextField
key={e.id}
size="small"
label={e.name}
required={e.pflicht}
value={eigenschaftValues[e.id] || ''}
onChange={ev => setEigenschaftValues(prev => ({ ...prev, [e.id]: ev.target.value }))}
sx={{ minWidth: 160 }}
/>
)
)}
</Box>
)}
<Box sx={{ mt: 1.5 }}>
<Button
variant="contained"
size="small"
startIcon={<AddIcon />}
onClick={() => handleAdd(item)}
disabled={isPending}
>
Zur Bestellung hinzufügen
</Button>
</Box>
</Box>
</TableCell>
</TableRow>
)}
</React.Fragment>
))}
</TableBody>
</Table>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Schließen</Button>
</DialogActions>
</Dialog>
);
}
// ══════════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════════
// Component // Component
// ══════════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════════
@@ -162,6 +503,7 @@ export default function BestellungDetail() {
const [selectedKatalogItem, setSelectedKatalogItem] = useState<AusruestungArtikel | null>(null); const [selectedKatalogItem, setSelectedKatalogItem] = useState<AusruestungArtikel | null>(null);
const [katalogEigenschaften, setKatalogEigenschaften] = useState<AusruestungEigenschaft[]>([]); const [katalogEigenschaften, setKatalogEigenschaften] = useState<AusruestungEigenschaft[]>([]);
const [eigenschaftValues, setEigenschaftValues] = useState<Record<number, string>>({}); const [eigenschaftValues, setEigenschaftValues] = useState<Record<number, string>>({});
const [katalogDialogOpen, setKatalogDialogOpen] = useState(false);
// ── Query ── // ── Query ──
const { data, isLoading, isError, error, refetch } = useQuery({ const { data, isLoading, isError, error, refetch } = useQuery({
@@ -898,6 +1240,17 @@ export default function BestellungDetail() {
<Paper sx={{ p: 2, mb: 3 }}> <Paper sx={{ p: 2, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" sx={{ flexGrow: 1 }}>Positionen</Typography> <Typography variant="h6" sx={{ flexGrow: 1 }}>Positionen</Typography>
{editMode && canCreate && (
<Button
size="small"
variant="outlined"
startIcon={<AddIcon />}
onClick={() => setKatalogDialogOpen(true)}
sx={{ ml: 1 }}
>
Aus Katalog
</Button>
)}
</Box> </Box>
<TableContainer> <TableContainer>
<Table size="small"> <Table size="small">
@@ -1441,6 +1794,17 @@ export default function BestellungDetail() {
confirmColor="error" confirmColor="error"
isLoading={deleteReminder.isPending} isLoading={deleteReminder.isPending}
/> />
{/* Katalog Add Dialog */}
<KatalogAddDialog
open={katalogDialogOpen}
onClose={() => setKatalogDialogOpen(false)}
onAddItem={(data) => {
addItem.mutate(data);
setKatalogDialogOpen(false);
}}
isPending={addItem.isPending}
/>
</DashboardLayout> </DashboardLayout>
); );
} }

View File

@@ -91,7 +91,7 @@ function Mitglieder() {
const [selectedStatus, setSelectedStatus] = useState<StatusEnum[]>([]); const [selectedStatus, setSelectedStatus] = useState<StatusEnum[]>([]);
const [selectedDienstgrad, setSelectedDienstgrad] = useState<DienstgradEnum[]>([]); const [selectedDienstgrad, setSelectedDienstgrad] = useState<DienstgradEnum[]>([]);
const [page, setPage] = useState(0); // MUI uses 0-based const [page, setPage] = useState(0); // MUI uses 0-based
const pageSize = 25; const [pageSize, setPageSize] = useState(25);
// Track previous debounced search to reset page // Track previous debounced search to reset page
const prevSearch = useRef(debouncedSearch); const prevSearch = useRef(debouncedSearch);
@@ -108,7 +108,7 @@ function Mitglieder() {
status: selectedStatus.length > 0 ? selectedStatus : undefined, status: selectedStatus.length > 0 ? selectedStatus : undefined,
dienstgrad: selectedDienstgrad.length > 0 ? selectedDienstgrad : undefined, dienstgrad: selectedDienstgrad.length > 0 ? selectedDienstgrad : undefined,
page: page + 1, // convert to 1-based for API page: page + 1, // convert to 1-based for API
pageSize, pageSize: pageSize === -1 ? 0 : pageSize, // -1 = MUI "Alle" sentinel → 0 = backend "no limit"
}); });
setMembers(items); setMembers(items);
setTotal(t); setTotal(t);
@@ -336,7 +336,12 @@ function Mitglieder() {
page={page} page={page}
onPageChange={(_e, newPage) => setPage(newPage)} onPageChange={(_e, newPage) => setPage(newPage)}
rowsPerPage={pageSize} rowsPerPage={pageSize}
rowsPerPageOptions={[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 }) => labelDisplayedRows={({ from, to, count }) =>
`${from}${to} von ${count !== -1 ? count : `mehr als ${to}`}` `${from}${to} von ${count !== -1 ? count : `mehr als ${to}`}`
} }