From ae3f0c825bffe8ca20a027e21082e72e59eb179a Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Fri, 27 Mar 2026 15:05:10 +0100 Subject: [PATCH] refactor: extract shared KatalogTab component, use it in both Bestellungen and Ausruestungsanfrage --- frontend/src/components/shared/KatalogTab.tsx | 310 ++++++++++++++++++ frontend/src/pages/Ausruestungsanfrage.tsx | 307 +---------------- frontend/src/pages/Bestellungen.tsx | 97 +----- 3 files changed, 318 insertions(+), 396 deletions(-) create mode 100644 frontend/src/components/shared/KatalogTab.tsx diff --git a/frontend/src/components/shared/KatalogTab.tsx b/frontend/src/components/shared/KatalogTab.tsx new file mode 100644 index 0000000..0a85691 --- /dev/null +++ b/frontend/src/components/shared/KatalogTab.tsx @@ -0,0 +1,310 @@ +import { useState, useMemo, useCallback } from 'react'; +import { + Box, Typography, Paper, Button, TextField, IconButton, + Chip, MenuItem, Divider, Tooltip, TableSortLabel, + Table, TableBody, TableCell, TableContainer, TableHead, TableRow, + Dialog, DialogTitle, DialogContent, DialogActions, +} from '@mui/material'; +import { + Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, + Check as CheckIcon, Close as CloseIcon, Settings as SettingsIcon, +} from '@mui/icons-material'; +import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { useNotification } from '../../contexts/NotificationContext'; +import { usePermissionContext } from '../../contexts/PermissionContext'; +import { ausruestungsanfrageApi } from '../../services/ausruestungsanfrage'; +import ChatAwareFab from './ChatAwareFab'; + +// ─── Category Management Dialog ────────────────────────────────────────────── + +interface KategorieDialogProps { + open: boolean; + onClose: () => void; +} + +export function KategorieDialog({ open, onClose }: KategorieDialogProps) { + const { showSuccess, showError } = useNotification(); + const queryClient = useQueryClient(); + const [newName, setNewName] = useState(''); + const [newParentId, setNewParentId] = useState(null); + const [editId, setEditId] = useState(null); + const [editName, setEditName] = useState(''); + + const { data: kategorien = [] } = useQuery({ + queryKey: ['ausruestungsanfrage', 'kategorien'], + queryFn: () => ausruestungsanfrageApi.getKategorien(), + enabled: open, + }); + + const topLevel = useMemo(() => kategorien.filter(k => !k.parent_id), [kategorien]); + const childrenOf = useCallback((parentId: number) => kategorien.filter(k => k.parent_id === parentId), [kategorien]); + + const createMut = useMutation({ + mutationFn: ({ name, parentId }: { name: string; parentId?: number | null }) => ausruestungsanfrageApi.createKategorie(name, parentId), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Kategorie erstellt'); setNewName(''); setNewParentId(null); }, + onError: () => showError('Fehler beim Erstellen'), + }); + + const updateMut = useMutation({ + mutationFn: ({ id, name }: { id: number; name: string }) => ausruestungsanfrageApi.updateKategorie(id, { name }), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Kategorie aktualisiert'); setEditId(null); }, + onError: () => showError('Fehler beim Aktualisieren'), + }); + + const deleteMut = useMutation({ + mutationFn: (id: number) => ausruestungsanfrageApi.deleteKategorie(id), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Kategorie gelöscht'); }, + onError: () => showError('Fehler beim Löschen'), + }); + + const renderKategorie = (k: { id: number; name: string; parent_id?: number | null }, indent: number) => { + const children = childrenOf(k.id); + return ( + + + {editId === k.id ? ( + <> + setEditName(e.target.value)} + sx={{ flexGrow: 1 }} + onKeyDown={e => { if (e.key === 'Enter' && editName.trim()) updateMut.mutate({ id: k.id, name: editName.trim() }); }} + /> + { if (editName.trim()) updateMut.mutate({ id: k.id, name: editName.trim() }); }}> + setEditId(null)}> + + ) : ( + <> + + {indent > 0 && '└ '}{k.name} + + + setNewParentId(k.id)}> + + { setEditId(k.id); setEditName(k.name); }}> + deleteMut.mutate(k.id)}> + + )} + + {children.map(c => renderKategorie(c, indent + 1))} + + ); + }; + + return ( + + Kategorien verwalten + + + setNewName(e.target.value)} + sx={{ flexGrow: 1 }} + onKeyDown={e => { if (e.key === 'Enter' && newName.trim()) createMut.mutate({ name: newName.trim(), parentId: newParentId }); }} + /> + {newParentId && ( + k.id === newParentId)?.name}`} + size="small" + onDelete={() => setNewParentId(null)} + /> + )} + + + + {topLevel.length === 0 ? ( + Keine Kategorien vorhanden. + ) : ( + topLevel.map(k => renderKategorie(k, 0)) + )} + + + + + + ); +} + +// ─── Catalog Tab ──────────────────────────────────────────────────────────── + +export function KatalogTab() { + const { showSuccess, showError } = useNotification(); + const { hasPermission } = usePermissionContext(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + const canManage = hasPermission('ausruestungsanfrage:manage_catalog'); + const canManageCategories = hasPermission('ausruestungsanfrage:manage_categories'); + + const [filterKategorie, setFilterKategorie] = useState(''); + const [kategorieDialogOpen, setKategorieDialogOpen] = useState(false); + const [search, setSearch] = useState(''); + const [sortField, setSortField] = useState<'bezeichnung' | 'kategorie'>('bezeichnung'); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); + + const { data: items = [], isLoading, isFetching } = useQuery({ + queryKey: ['ausruestungsanfrage', 'items', filterKategorie, search], + queryFn: () => ausruestungsanfrageApi.getItems({ + ...(filterKategorie ? { kategorie_id: filterKategorie as number } : {}), + ...(search.trim() ? { search: search.trim() } : {}), + }), + placeholderData: keepPreviousData, + }); + + const { data: kategorien = [] } = useQuery({ + queryKey: ['ausruestungsanfrage', 'kategorien'], + queryFn: () => ausruestungsanfrageApi.getKategorien(), + }); + + const kategorieOptions = useMemo(() => { + const map = new Map(kategorien.map(k => [k.id, k])); + const getDisplayName = (k: { id: number; name: string; parent_id?: number | null }): string => { + if (k.parent_id) { + const parent = map.get(k.parent_id); + if (parent) return `${parent.name} > ${k.name}`; + } + return k.name; + }; + return kategorien.map(k => ({ id: k.id, name: getDisplayName(k), isChild: !!k.parent_id })); + }, [kategorien]); + + const deleteItemMut = useMutation({ + mutationFn: (id: number) => ausruestungsanfrageApi.deleteItem(id), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Artikel gelöscht'); }, + onError: () => showError('Fehler beim Löschen'), + }); + + const handleSort = (field: 'bezeichnung' | 'kategorie') => { + if (sortField === field) { + setSortDir(prev => prev === 'asc' ? 'desc' : 'asc'); + } else { + setSortField(field); + setSortDir('asc'); + } + }; + + 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 || a.kategorie || '').toLowerCase(); + bVal = (kategorieOptions.find(k => k.id === b.kategorie_id)?.name || b.kategorie_name || b.kategorie || '').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]); + + return ( + + + setSearch(e.target.value)} + sx={{ minWidth: 200 }} + placeholder="Artikel suchen..." + /> + setFilterKategorie(e.target.value as number | '')} + sx={{ minWidth: 200 }} + > + Alle + {kategorieOptions.map(k => {k.name})} + + {canManageCategories && ( + + setKategorieDialogOpen(true)}> + + + + )} + + + {isLoading ? ( + Lade Katalog... + ) : items.length === 0 && !isFetching ? ( + Keine Artikel vorhanden. + ) : ( + + + + + + handleSort('bezeichnung')}> + Bezeichnung + + + + handleSort('kategorie')}> + Kategorie + + + Beschreibung + {canManage && Aktionen} + + + + {sortedItems.map(item => ( + navigate(`/ausruestungsanfrage/artikel/${item.id}`)} + > + + + {item.bezeichnung} + {(item.eigenschaften_count ?? 0) > 0 && ( + + )} + + + {kategorieOptions.find(k => k.id === item.kategorie_id)?.name || item.kategorie_name || item.kategorie || '-'} + + {item.beschreibung || '-'} + + {canManage && ( + + { e.stopPropagation(); deleteItemMut.mutate(item.id); }}> + + )} + + ))} + +
+
+ )} + + setKategorieDialogOpen(false)} /> + + {canManage && ( + navigate('/ausruestungsanfrage/artikel/neu')} aria-label="Artikel hinzufügen"> + + + )} +
+ ); +} diff --git a/frontend/src/pages/Ausruestungsanfrage.tsx b/frontend/src/pages/Ausruestungsanfrage.tsx index b64310b..79e2da8 100644 --- a/frontend/src/pages/Ausruestungsanfrage.tsx +++ b/frontend/src/pages/Ausruestungsanfrage.tsx @@ -1,21 +1,17 @@ -import { useState, useMemo, useEffect, useCallback } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import { Box, Tab, Tabs, Typography, Grid, Button, Chip, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, - Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton, - MenuItem, Divider, Tooltip, TableSortLabel, + TextField, IconButton, MenuItem, } from '@mui/material'; -import { - Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, - Check as CheckIcon, Close as CloseIcon, Settings as SettingsIcon, -} from '@mui/icons-material'; -import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'; +import { Add as AddIcon } from '@mui/icons-material'; +import { useQuery } from '@tanstack/react-query'; import { useSearchParams, useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import ChatAwareFab from '../components/shared/ChatAwareFab'; -import { useNotification } from '../contexts/NotificationContext'; import { usePermissionContext } from '../contexts/PermissionContext'; import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; +import { KatalogTab } from '../components/shared/KatalogTab'; import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/ausruestungsanfrage.types'; import type { AusruestungAnfrageStatus, AusruestungAnfrage, @@ -33,299 +29,6 @@ function formatOrderId(r: AusruestungAnfrage): string { const ACTIVE_STATUSES: AusruestungAnfrageStatus[] = ['offen', 'genehmigt', 'bestellt']; -// ─── Category Management Dialog ────────────────────────────────────────────── - -interface KategorieDialogProps { - open: boolean; - onClose: () => void; -} - -function KategorieDialog({ open, onClose }: KategorieDialogProps) { - const { showSuccess, showError } = useNotification(); - const queryClient = useQueryClient(); - const [newName, setNewName] = useState(''); - const [newParentId, setNewParentId] = useState(null); - const [editId, setEditId] = useState(null); - const [editName, setEditName] = useState(''); - - const { data: kategorien = [] } = useQuery({ - queryKey: ['ausruestungsanfrage', 'kategorien'], - queryFn: () => ausruestungsanfrageApi.getKategorien(), - enabled: open, - }); - - const topLevel = useMemo(() => kategorien.filter(k => !k.parent_id), [kategorien]); - const childrenOf = useCallback((parentId: number) => kategorien.filter(k => k.parent_id === parentId), [kategorien]); - - const createMut = useMutation({ - mutationFn: ({ name, parentId }: { name: string; parentId?: number | null }) => ausruestungsanfrageApi.createKategorie(name, parentId), - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Kategorie erstellt'); setNewName(''); setNewParentId(null); }, - onError: () => showError('Fehler beim Erstellen'), - }); - - const updateMut = useMutation({ - mutationFn: ({ id, name }: { id: number; name: string }) => ausruestungsanfrageApi.updateKategorie(id, { name }), - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Kategorie aktualisiert'); setEditId(null); }, - onError: () => showError('Fehler beim Aktualisieren'), - }); - - const deleteMut = useMutation({ - mutationFn: (id: number) => ausruestungsanfrageApi.deleteKategorie(id), - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Kategorie gelöscht'); }, - onError: () => showError('Fehler beim Löschen'), - }); - - const renderKategorie = (k: { id: number; name: string; parent_id?: number | null }, indent: number) => { - const children = childrenOf(k.id); - return ( - - - {editId === k.id ? ( - <> - setEditName(e.target.value)} - sx={{ flexGrow: 1 }} - onKeyDown={e => { if (e.key === 'Enter' && editName.trim()) updateMut.mutate({ id: k.id, name: editName.trim() }); }} - /> - { if (editName.trim()) updateMut.mutate({ id: k.id, name: editName.trim() }); }}> - setEditId(null)}> - - ) : ( - <> - - {indent > 0 && '└ '}{k.name} - - - setNewParentId(k.id)}> - - { setEditId(k.id); setEditName(k.name); }}> - deleteMut.mutate(k.id)}> - - )} - - {children.map(c => renderKategorie(c, indent + 1))} - - ); - }; - - return ( - - Kategorien verwalten - - - setNewName(e.target.value)} - sx={{ flexGrow: 1 }} - onKeyDown={e => { if (e.key === 'Enter' && newName.trim()) createMut.mutate({ name: newName.trim(), parentId: newParentId }); }} - /> - {newParentId && ( - k.id === newParentId)?.name}`} - size="small" - onDelete={() => setNewParentId(null)} - /> - )} - - - - {topLevel.length === 0 ? ( - Keine Kategorien vorhanden. - ) : ( - topLevel.map(k => renderKategorie(k, 0)) - )} - - - - - - ); -} - -// ─── Catalog Tab ──────────────────────────────────────────────────────────── - -function KatalogTab() { - const { showSuccess, showError } = useNotification(); - const { hasPermission } = usePermissionContext(); - const queryClient = useQueryClient(); - const navigate = useNavigate(); - - const canManage = hasPermission('ausruestungsanfrage:manage_catalog'); - const canManageCategories = hasPermission('ausruestungsanfrage:manage_categories'); - - const [filterKategorie, setFilterKategorie] = useState(''); - const [kategorieDialogOpen, setKategorieDialogOpen] = useState(false); - const [search, setSearch] = useState(''); - const [sortField, setSortField] = useState<'bezeichnung' | 'kategorie'>('bezeichnung'); - const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); - - const { data: items = [], isLoading, isFetching } = useQuery({ - queryKey: ['ausruestungsanfrage', 'items', filterKategorie, search], - queryFn: () => ausruestungsanfrageApi.getItems({ - ...(filterKategorie ? { kategorie_id: filterKategorie as number } : {}), - ...(search.trim() ? { search: search.trim() } : {}), - }), - placeholderData: keepPreviousData, - }); - - const { data: kategorien = [] } = useQuery({ - queryKey: ['ausruestungsanfrage', 'kategorien'], - queryFn: () => ausruestungsanfrageApi.getKategorien(), - }); - - const kategorieOptions = useMemo(() => { - const map = new Map(kategorien.map(k => [k.id, k])); - const getDisplayName = (k: { id: number; name: string; parent_id?: number | null }): string => { - if (k.parent_id) { - const parent = map.get(k.parent_id); - if (parent) return `${parent.name} > ${k.name}`; - } - return k.name; - }; - return kategorien.map(k => ({ id: k.id, name: getDisplayName(k), isChild: !!k.parent_id })); - }, [kategorien]); - - const deleteItemMut = useMutation({ - mutationFn: (id: number) => ausruestungsanfrageApi.deleteItem(id), - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Artikel geloescht'); }, - onError: () => showError('Fehler beim Loeschen'), - }); - - const handleSort = (field: 'bezeichnung' | 'kategorie') => { - if (sortField === field) { - setSortDir(prev => prev === 'asc' ? 'desc' : 'asc'); - } else { - setSortField(field); - setSortDir('asc'); - } - }; - - 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 || a.kategorie || '').toLowerCase(); - bVal = (kategorieOptions.find(k => k.id === b.kategorie_id)?.name || b.kategorie_name || b.kategorie || '').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]); - - return ( - - - setSearch(e.target.value)} - sx={{ minWidth: 200 }} - placeholder="Artikel suchen..." - /> - setFilterKategorie(e.target.value as number | '')} - sx={{ minWidth: 200 }} - > - Alle - {kategorieOptions.map(k => {k.name})} - - {canManageCategories && ( - - setKategorieDialogOpen(true)}> - - - - )} - - - {isLoading ? ( - Lade Katalog... - ) : items.length === 0 && !isFetching ? ( - Keine Artikel vorhanden. - ) : ( - - - - - - handleSort('bezeichnung')}> - Bezeichnung - - - - handleSort('kategorie')}> - Kategorie - - - Beschreibung - {canManage && Aktionen} - - - - {sortedItems.map(item => ( - navigate(`/ausruestungsanfrage/artikel/${item.id}`)} - > - - - {item.bezeichnung} - {(item.eigenschaften_count ?? 0) > 0 && ( - - )} - - - {kategorieOptions.find(k => k.id === item.kategorie_id)?.name || item.kategorie_name || item.kategorie || '-'} - - {item.beschreibung || '-'} - - {canManage && ( - - { e.stopPropagation(); deleteItemMut.mutate(item.id); }}> - - )} - - ))} - -
-
- )} - - setKategorieDialogOpen(false)} /> - - {canManage && ( - navigate('/ausruestungsanfrage/artikel/neu')} aria-label="Artikel hinzufuegen"> - - - )} -
- ); -} - // ─── My Requests Tab ──────────────────────────────────────────────────────── function MeineAnfragenTab() { diff --git a/frontend/src/pages/Bestellungen.tsx b/frontend/src/pages/Bestellungen.tsx index 244a371..ac3d009 100644 --- a/frontend/src/pages/Bestellungen.tsx +++ b/frontend/src/pages/Bestellungen.tsx @@ -26,11 +26,12 @@ import { TextField, MenuItem, } from '@mui/material'; -import { Add as AddIcon, ExpandMore as ExpandMoreIcon, FilterList as FilterListIcon, PictureAsPdf as PdfIcon, Search as SearchIcon } from '@mui/icons-material'; +import { Add as AddIcon, ExpandMore as ExpandMoreIcon, FilterList as FilterListIcon, PictureAsPdf as PdfIcon } from '@mui/icons-material'; import { useQuery } from '@tanstack/react-query'; import { useNavigate, useSearchParams } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import ChatAwareFab from '../components/shared/ChatAwareFab'; +import { KatalogTab } from '../components/shared/KatalogTab'; import { usePermissionContext } from '../contexts/PermissionContext'; import { bestellungApi } from '../services/bestellung'; import { configApi } from '../services/config'; @@ -87,7 +88,6 @@ export default function Bestellungen() { const { hasPermission } = usePermissionContext(); const canManageVendors = hasPermission('bestellungen:manage_vendors'); const canExport = hasPermission('bestellungen:export'); - const canManageCatalog = hasPermission('ausruestungsanfrage:manage_catalog'); // Tab from URL const [tab, setTab] = useState(() => { @@ -116,26 +116,6 @@ export default function Bestellungen() { queryFn: bestellungApi.getVendors, }); - // ── Katalog state ── - const [katalogSearch, setKatalogSearch] = useState(''); - const [katalogKategorie, setKatalogKategorie] = useState(''); - - const { data: katalogItems = [], isLoading: katalogLoading } = useQuery({ - queryKey: ['katalogItems', katalogSearch, katalogKategorie], - queryFn: () => bestellungApi.getKatalogItems({ - search: katalogSearch || undefined, - kategorie: katalogKategorie || undefined, - }), - enabled: tab === 2, - }); - - const { data: katalogKategorien = [] } = useQuery({ - queryKey: ['katalogKategorien'], - queryFn: bestellungApi.getKatalogKategorien, - enabled: tab === 2, - staleTime: 5 * 60 * 1000, - }); - // ── Derive unique filter values from data ── const uniqueVendors = useMemo(() => { const map = new Map(); @@ -485,78 +465,7 @@ export default function Bestellungen() { {/* ── Tab 2: Katalog ── */} - - setKatalogSearch(e.target.value)} - InputProps={{ startAdornment: }} - sx={{ flex: 1, maxWidth: 400 }} - /> - setKatalogKategorie(e.target.value)} - sx={{ minWidth: 180 }} - > - Alle Kategorien - {katalogKategorien.map((k) => ( - {k} - ))} - - - - - - - - Bezeichnung - Kategorie - Geschätzter Preis - Bevorzugter Lieferant - Eigenschaften - - - - {katalogLoading ? ( - Laden... - ) : katalogItems.length === 0 ? ( - Keine Artikel gefunden - ) : ( - katalogItems.map((item) => ( - navigate(`/ausruestungsanfrage/artikel/${item.id}`)} - > - - {item.bezeichnung} - {item.beschreibung && ( - - {item.beschreibung} - - )} - - {item.kategorie_name || item.kategorie || '–'} - {item.geschaetzter_preis != null ? formatCurrency(item.geschaetzter_preis) : '–'} - {item.bevorzugter_lieferant_name || '–'} - {item.eigenschaften_count ?? 0} - - )) - )} - -
-
- - {canManageCatalog && ( - navigate('/ausruestungsanfrage/artikel/neu')} aria-label="Neuer Katalogartikel"> - - - )} +
);