diff --git a/backend/src/controllers/member.controller.ts b/backend/src/controllers/member.controller.ts index 1c28d99..412f610 100644 --- a/backend/src/controllers/member.controller.ts +++ b/backend/src/controllers/member.controller.ts @@ -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({ diff --git a/backend/src/database/migrations/088_add_sachbearbeiter_dienstgrad.sql b/backend/src/database/migrations/088_add_sachbearbeiter_dienstgrad.sql new file mode 100644 index 0000000..c33ff61 --- /dev/null +++ b/backend/src/database/migrations/088_add_sachbearbeiter_dienstgrad.sql @@ -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' + )); diff --git a/backend/src/services/member.service.ts b/backend/src/services/member.service.ts index c330d42..df3def9 100644 --- a/backend/src/services/member.service.ts +++ b/backend/src/services/member.service.ts @@ -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) => ({ diff --git a/frontend/src/pages/BestellungDetail.tsx b/frontend/src/pages/BestellungDetail.tsx index 56b0ee8..8a40444 100644 --- a/frontend/src/pages/BestellungDetail.tsx +++ b/frontend/src/pages/BestellungDetail.tsx @@ -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 = { // 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(''); + const [expandedId, setExpandedId] = useState(null); + const [itemConfig, setItemConfig] = useState({ menge: 1, einheit: 'Stk', einzelpreis: '', artikelnummer: '' }); + const [eigenschaftValues, setEigenschaftValues] = useState>({}); + 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 ( + + Artikel aus Katalog hinzufügen + + {/* Filters */} + + setSearch(e.target.value)} + placeholder="Artikel suchen..." + sx={{ minWidth: 200 }} + /> + setFilterKategorie(e.target.value as number | '')} + sx={{ minWidth: 180 }} + > + Alle Kategorien + {kategorieOptions.map(k => ( + {k.name} + ))} + + {isFetching && Lade...} + + + {/* Table */} + + {items.length === 0 && !isFetching ? ( + + Keine Artikel gefunden. + + ) : ( + + + + + handleSort('bezeichnung')} + > + Bezeichnung + + + + handleSort('kategorie')} + > + Kategorie + + + Beschreibung + Richtpreis + + + + + {sortedItems.map(item => ( + + handleToggleExpand(item)} + > + + + {item.bezeichnung} + {(item.eigenschaften_count ?? 0) > 0 && ( + + )} + + + + {kategorieOptions.find(k => k.id === item.kategorie_id)?.name || item.kategorie_name || '–'} + + + {item.beschreibung || '–'} + + + {item.geschaetzter_preis != null + ? new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(item.geschaetzter_preis) + : '–'} + + + + + + + {/* Expanded configuration row */} + {expandedId === item.id && ( + + + + + {item.bezeichnung} — Konfigurieren + + + setItemConfig(c => ({ ...c, menge: Math.max(1, Number(e.target.value)) }))} + inputProps={{ min: 1 }} + sx={{ width: 90 }} + /> + setItemConfig(c => ({ ...c, einheit: e.target.value }))} + sx={{ width: 90 }} + /> + setItemConfig(c => ({ ...c, einzelpreis: e.target.value }))} + placeholder="EUR" + sx={{ width: 120 }} + /> + setItemConfig(c => ({ ...c, artikelnummer: e.target.value }))} + sx={{ width: 160 }} + /> + + + {/* Eigenschaften */} + {eigenschaften.length > 0 && ( + + {eigenschaften.map(e => + e.typ === 'options' && e.optionen?.length ? ( + setEigenschaftValues(prev => ({ ...prev, [e.id]: ev.target.value }))} + sx={{ minWidth: 140 }} + > + + {e.optionen.map(opt => ( + {opt} + ))} + + ) : ( + setEigenschaftValues(prev => ({ ...prev, [e.id]: ev.target.value }))} + sx={{ minWidth: 160 }} + /> + ) + )} + + )} + + + + + + + + )} + + ))} + +
+ )} +
+
+ + + +
+ ); +} + // ══════════════════════════════════════════════════════════════════════════════ // Component // ══════════════════════════════════════════════════════════════════════════════ @@ -162,6 +503,7 @@ export default function BestellungDetail() { const [selectedKatalogItem, setSelectedKatalogItem] = useState(null); const [katalogEigenschaften, setKatalogEigenschaften] = useState([]); const [eigenschaftValues, setEigenschaftValues] = useState>({}); + const [katalogDialogOpen, setKatalogDialogOpen] = useState(false); // ── Query ── const { data, isLoading, isError, error, refetch } = useQuery({ @@ -898,6 +1240,17 @@ export default function BestellungDetail() { Positionen + {editMode && canCreate && ( + + )} @@ -1441,6 +1794,17 @@ export default function BestellungDetail() { confirmColor="error" isLoading={deleteReminder.isPending} /> + + {/* Katalog Add Dialog */} + setKatalogDialogOpen(false)} + onAddItem={(data) => { + addItem.mutate(data); + setKatalogDialogOpen(false); + }} + isPending={addItem.isPending} + /> ); } diff --git a/frontend/src/pages/Mitglieder.tsx b/frontend/src/pages/Mitglieder.tsx index 05b17e6..c811ef4 100644 --- a/frontend/src/pages/Mitglieder.tsx +++ b/frontend/src/pages/Mitglieder.tsx @@ -91,7 +91,7 @@ function Mitglieder() { const [selectedStatus, setSelectedStatus] = useState([]); const [selectedDienstgrad, setSelectedDienstgrad] = useState([]); 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}`}` }