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,
|
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({
|
||||||
|
|||||||
@@ -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,9 +163,34 @@ 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;
|
||||||
|
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
|
SELECT
|
||||||
u.id,
|
u.id,
|
||||||
u.name,
|
u.name,
|
||||||
@@ -188,6 +213,7 @@ class MemberService {
|
|||||||
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
|
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
|
||||||
`;
|
`;
|
||||||
values.push(pageSize, offset);
|
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) => ({
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`}`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user