fix(sync): add Sachbearbeiter to dienstgrad constraint; add catalog browser dialog for external order position
This commit is contained in:
@@ -61,7 +61,7 @@ class MemberController {
|
||||
status: normalizeArray(statusParam) as any,
|
||||
dienstgrad: normalizeArray(dienstgradParam) as any,
|
||||
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({
|
||||
|
||||
@@ -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'
|
||||
));
|
||||
@@ -163,31 +163,57 @@ class MemberService {
|
||||
}
|
||||
|
||||
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
||||
const offset = (page - 1) * pageSize;
|
||||
const fetchAll = pageSize === 0;
|
||||
|
||||
const 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);
|
||||
let dataQuery: string;
|
||||
if (fetchAll) {
|
||||
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
|
||||
`;
|
||||
} 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 = `
|
||||
SELECT COUNT(*)::INTEGER AS total
|
||||
@@ -196,9 +222,11 @@ class MemberService {
|
||||
${whereClause}
|
||||
`;
|
||||
|
||||
const countValues = fetchAll ? values : values.slice(0, values.length - 2);
|
||||
|
||||
const [dataResult, countResult] = await Promise.all([
|
||||
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) => ({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableSortLabel,
|
||||
TextField,
|
||||
IconButton,
|
||||
Grid,
|
||||
@@ -26,6 +27,10 @@ import {
|
||||
AccordionDetails,
|
||||
Autocomplete,
|
||||
Tooltip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon,
|
||||
@@ -40,7 +45,7 @@ import {
|
||||
Save as SaveIcon,
|
||||
PictureAsPdf as PdfIcon,
|
||||
} 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 DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import GermanDateField from '../components/shared/GermanDateField';
|
||||
@@ -52,7 +57,7 @@ import { configApi } from '../services/config';
|
||||
import { addPdfHeader, addPdfFooter } from '../utils/pdfExport';
|
||||
import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } 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';
|
||||
|
||||
// ── Helpers ──
|
||||
@@ -111,6 +116,342 @@ const STATUS_TRANSITIONS: Record<BestellungStatus, BestellungStatus[]> = {
|
||||
// Empty line item form
|
||||
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
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -162,6 +503,7 @@ export default function BestellungDetail() {
|
||||
const [selectedKatalogItem, setSelectedKatalogItem] = useState<AusruestungArtikel | null>(null);
|
||||
const [katalogEigenschaften, setKatalogEigenschaften] = useState<AusruestungEigenschaft[]>([]);
|
||||
const [eigenschaftValues, setEigenschaftValues] = useState<Record<number, string>>({});
|
||||
const [katalogDialogOpen, setKatalogDialogOpen] = useState(false);
|
||||
|
||||
// ── Query ──
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
@@ -898,6 +1240,17 @@ export default function BestellungDetail() {
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<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>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
@@ -1441,6 +1794,17 @@ export default function BestellungDetail() {
|
||||
confirmColor="error"
|
||||
isLoading={deleteReminder.isPending}
|
||||
/>
|
||||
|
||||
{/* Katalog Add Dialog */}
|
||||
<KatalogAddDialog
|
||||
open={katalogDialogOpen}
|
||||
onClose={() => setKatalogDialogOpen(false)}
|
||||
onAddItem={(data) => {
|
||||
addItem.mutate(data);
|
||||
setKatalogDialogOpen(false);
|
||||
}}
|
||||
isPending={addItem.isPending}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ function Mitglieder() {
|
||||
const [selectedStatus, setSelectedStatus] = useState<StatusEnum[]>([]);
|
||||
const [selectedDienstgrad, setSelectedDienstgrad] = useState<DienstgradEnum[]>([]);
|
||||
const [page, setPage] = useState(0); // MUI uses 0-based
|
||||
const pageSize = 25;
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
|
||||
// Track previous debounced search to reset page
|
||||
const prevSearch = useRef(debouncedSearch);
|
||||
@@ -108,7 +108,7 @@ function Mitglieder() {
|
||||
status: selectedStatus.length > 0 ? selectedStatus : undefined,
|
||||
dienstgrad: selectedDienstgrad.length > 0 ? selectedDienstgrad : undefined,
|
||||
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);
|
||||
setTotal(t);
|
||||
@@ -336,7 +336,12 @@ function Mitglieder() {
|
||||
page={page}
|
||||
onPageChange={(_e, newPage) => setPage(newPage)}
|
||||
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 }) =>
|
||||
`${from}–${to} von ${count !== -1 ? count : `mehr als ${to}`}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user