From 35b3718e3854357f1c3a25ad63c244f357ab4e8c Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Fri, 27 Mar 2026 13:17:05 +0100 Subject: [PATCH] catalog search/sort, edit-page characteristics, preferred vendor per article --- .../060_add_bevorzugter_lieferant.sql | 3 + .../services/ausruestungsanfrage.service.ts | 36 ++++-- frontend/src/pages/Ausruestungsanfrage.tsx | 61 +++++++++- .../AusruestungsanfrageArtikelDetail.tsx | 106 ++++++++++++++++-- .../src/pages/AusruestungsanfrageDetail.tsx | 67 ++++++++++- .../pages/AusruestungsanfrageZuBestellung.tsx | 27 +++++ frontend/src/services/ausruestungsanfrage.ts | 3 +- .../src/types/ausruestungsanfrage.types.ts | 3 + 8 files changed, 277 insertions(+), 29 deletions(-) create mode 100644 backend/src/database/migrations/060_add_bevorzugter_lieferant.sql diff --git a/backend/src/database/migrations/060_add_bevorzugter_lieferant.sql b/backend/src/database/migrations/060_add_bevorzugter_lieferant.sql new file mode 100644 index 0000000..614bb72 --- /dev/null +++ b/backend/src/database/migrations/060_add_bevorzugter_lieferant.sql @@ -0,0 +1,3 @@ +-- Add preferred vendor to catalog items +ALTER TABLE ausruestung_artikel + ADD COLUMN IF NOT EXISTS bevorzugter_lieferant_id INT REFERENCES lieferanten(id) ON DELETE SET NULL; diff --git a/backend/src/services/ausruestungsanfrage.service.ts b/backend/src/services/ausruestungsanfrage.service.ts index be54fb8..ef95d86 100644 --- a/backend/src/services/ausruestungsanfrage.service.ts +++ b/backend/src/services/ausruestungsanfrage.service.ts @@ -55,26 +55,34 @@ async function deleteKategorie(id: number) { // Catalog Items (ausruestung_artikel) // --------------------------------------------------------------------------- -async function getItems(filters?: { kategorie?: string; kategorie_id?: number; aktiv?: boolean }) { +async function getItems(filters?: { kategorie?: string; kategorie_id?: number; aktiv?: boolean; search?: string }) { const conditions: string[] = []; const params: unknown[] = []; if (filters?.kategorie) { params.push(filters.kategorie); - conditions.push(`kategorie = $${params.length}`); + conditions.push(`aa.kategorie = $${params.length}`); } if (filters?.kategorie_id) { params.push(filters.kategorie_id); - conditions.push(`kategorie_id = $${params.length}`); + conditions.push(`aa.kategorie_id = $${params.length}`); } if (filters?.aktiv !== undefined) { params.push(filters.aktiv); - conditions.push(`aktiv = $${params.length}`); + conditions.push(`aa.aktiv = $${params.length}`); + } + if (filters?.search) { + params.push(`%${filters.search}%`); + conditions.push(`aa.bezeichnung ILIKE $${params.length}`); } const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const result = await pool.query( - `SELECT * FROM ausruestung_artikel ${where} ORDER BY kategorie, bezeichnung`, + `SELECT aa.*, aa.bevorzugter_lieferant_id, l.name AS bevorzugter_lieferant_name + FROM ausruestung_artikel aa + LEFT JOIN lieferanten l ON l.id = aa.bevorzugter_lieferant_id + ${where} + ORDER BY aa.kategorie, aa.bezeichnung`, params, ); @@ -102,7 +110,13 @@ async function getItems(filters?: { kategorie?: string; kategorie_id?: number; a } async function getItemById(id: number) { - const result = await pool.query('SELECT * FROM ausruestung_artikel WHERE id = $1', [id]); + const result = await pool.query( + `SELECT aa.*, aa.bevorzugter_lieferant_id, l.name AS bevorzugter_lieferant_name + FROM ausruestung_artikel aa + LEFT JOIN lieferanten l ON l.id = aa.bevorzugter_lieferant_id + WHERE aa.id = $1`, + [id], + ); if (!result.rows[0]) return null; const row = result.rows[0]; @@ -137,12 +151,13 @@ async function createItem( kategorie_id?: number; geschaetzter_preis?: number; aktiv?: boolean; + bevorzugter_lieferant_id?: number | null; }, userId: string, ) { // Build column list dynamically based on whether kategorie_id column exists - const cols = ['bezeichnung', 'beschreibung', 'kategorie', 'geschaetzter_preis', 'aktiv', 'erstellt_von']; - const vals = [data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.geschaetzter_preis || null, data.aktiv ?? true, userId]; + const cols = ['bezeichnung', 'beschreibung', 'kategorie', 'geschaetzter_preis', 'aktiv', 'erstellt_von', 'bevorzugter_lieferant_id']; + const vals = [data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.geschaetzter_preis || null, data.aktiv ?? true, userId, data.bevorzugter_lieferant_id ?? null]; if (data.kategorie_id) { cols.push('kategorie_id'); @@ -166,6 +181,7 @@ async function updateItem( kategorie_id?: number | null; geschaetzter_preis?: number; aktiv?: boolean; + bevorzugter_lieferant_id?: number | null; }, _userId: string, ) { @@ -196,6 +212,10 @@ async function updateItem( params.push(data.aktiv); fields.push(`aktiv = $${params.length}`); } + if (data.bevorzugter_lieferant_id !== undefined) { + params.push(data.bevorzugter_lieferant_id); + fields.push(`bevorzugter_lieferant_id = $${params.length}`); + } if (fields.length === 0) { return getItemById(id); diff --git a/frontend/src/pages/Ausruestungsanfrage.tsx b/frontend/src/pages/Ausruestungsanfrage.tsx index a331d3d..eeb82ee 100644 --- a/frontend/src/pages/Ausruestungsanfrage.tsx +++ b/frontend/src/pages/Ausruestungsanfrage.tsx @@ -3,7 +3,7 @@ import { Box, Tab, Tabs, Typography, Grid, Button, Chip, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton, - MenuItem, Divider, Tooltip, + MenuItem, Divider, Tooltip, TableSortLabel, } from '@mui/material'; import { Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, @@ -166,10 +166,16 @@ function KatalogTab() { 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 } = useQuery({ - queryKey: ['ausruestungsanfrage', 'items', filterKategorie], - queryFn: () => ausruestungsanfrageApi.getItems(filterKategorie ? { kategorie_id: filterKategorie as number } : undefined), + queryKey: ['ausruestungsanfrage', 'items', filterKategorie, search], + queryFn: () => ausruestungsanfrageApi.getItems({ + ...(filterKategorie ? { kategorie_id: filterKategorie as number } : {}), + ...(search.trim() ? { search: search.trim() } : {}), + }), }); const { data: kategorien = [] } = useQuery({ @@ -195,9 +201,44 @@ function KatalogTab() { 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..." + /> - Bezeichnung - Kategorie + + handleSort('bezeichnung')}> + Bezeichnung + + + + handleSort('kategorie')}> + Kategorie + + Beschreibung {canManage && Aktionen} - {items.map(item => ( + {sortedItems.map(item => ( (null); + const [editName, setEditName] = useState(''); + const [editTyp, setEditTyp] = useState<'options' | 'freitext'>('options'); + const [editOptionen, setEditOptionen] = useState(''); + const [editPflicht, setEditPflicht] = useState(false); + const { data: eigenschaften = [] } = useQuery({ queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId], queryFn: () => ausruestungsanfrageApi.getArtikelEigenschaften(artikelId!), @@ -38,7 +48,14 @@ function EigenschaftenEditor({ artikelId }: { artikelId: number | null }) { const upsertMut = useMutation({ mutationFn: (data: { eigenschaft_id?: number; name: string; typ: string; optionen?: string[]; pflicht?: boolean; sort_order?: number }) => ausruestungsanfrageApi.upsertArtikelEigenschaft(artikelId!, data), - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId] }); showSuccess('Eigenschaft gespeichert'); }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId] }); + showSuccess('Eigenschaft gespeichert'); + setNewName(''); + setNewOptionen(''); + setNewPflicht(false); + setEditingId(null); + }, onError: () => showError('Fehler beim Speichern'), }); @@ -58,9 +75,26 @@ function EigenschaftenEditor({ artikelId }: { artikelId: number | null }) { pflicht: newPflicht, sort_order: eigenschaften.length, }); - setNewName(''); - setNewOptionen(''); - setNewPflicht(false); + }; + + const startEditEigenschaft = (e: AusruestungEigenschaft) => { + setEditingId(e.id); + setEditName(e.name); + setEditTyp(e.typ); + setEditOptionen(e.optionen?.join(', ') || ''); + setEditPflicht(e.pflicht); + }; + + const handleSaveEdit = (e: AusruestungEigenschaft) => { + if (!editName.trim()) return; + upsertMut.mutate({ + eigenschaft_id: e.id, + name: editName.trim(), + typ: editTyp, + optionen: editTyp === 'options' ? editOptionen.split(',').map(s => s.trim()).filter(Boolean) : undefined, + pflicht: editPflicht, + sort_order: e.sort_order, + }); }; if (artikelId == null) return Bitte speichern Sie den Artikel zuerst, bevor Sie Eigenschaften hinzufuegen.; @@ -69,12 +103,42 @@ function EigenschaftenEditor({ artikelId }: { artikelId: number | null }) { Eigenschaften {eigenschaften.map(e => ( - - - {e.name} ({e.typ === 'options' ? `Auswahl: ${e.optionen?.join(', ')}` : 'Freitext'}) - {e.pflicht && } - - deleteMut.mutate(e.id)}> + + {editingId === e.id ? ( + + + setEditName(ev.target.value)} sx={{ flexGrow: 1 }} /> + setEditTyp(ev.target.value as 'options' | 'freitext')} + sx={{ minWidth: 120 }} + > + Auswahl + Freitext + + setEditPflicht(ev.target.checked)} />} + label="Pflicht" + /> + + {editTyp === 'options' && ( + setEditOptionen(ev.target.value)} fullWidth /> + )} + + + + + + ) : ( + + + {e.name} ({e.typ === 'options' ? `Auswahl: ${e.optionen?.join(', ')}` : 'Freitext'}) + {e.pflicht && } + + startEditEigenschaft(e)}> + deleteMut.mutate(e.id)}> + + )} ))} @@ -158,6 +222,12 @@ export default function AusruestungsanfrageArtikelDetail() { enabled: artikelId != null, }); + const { data: lieferanten = [] } = useQuery({ + queryKey: ['bestellungen', 'lieferanten'], + queryFn: () => bestellungApi.getVendors(), + enabled: editing || isCreate, + }); + const topKategorien = useMemo(() => kategorien.filter(k => !k.parent_id), [kategorien]); const subKategorienOf = useCallback((parentId: number) => kategorien.filter(k => k.parent_id === parentId), [kategorien]); const subKats = useMemo(() => mainKat ? subKategorienOf(mainKat as number) : [], [mainKat, subKategorienOf]); @@ -213,6 +283,7 @@ export default function AusruestungsanfrageArtikelDetail() { bezeichnung: artikel.bezeichnung, beschreibung: artikel.beschreibung, kategorie_id: artikel.kategorie_id ?? null, + bevorzugter_lieferant_id: artikel.bevorzugter_lieferant_id ?? null, }); const kat = kategorien.find(k => k.id === artikel.kategorie_id); if (kat?.parent_id) { @@ -321,6 +392,15 @@ export default function AusruestungsanfrageArtikelDetail() { )} + l.name} + value={lieferanten.find(l => l.id === form.bevorzugter_lieferant_id) || null} + onChange={(_, v) => setForm(f => ({ ...f, bevorzugter_lieferant_id: v?.id ?? null }))} + renderInput={params => } + fullWidth + /> + {canManage && } @@ -363,6 +443,12 @@ export default function AusruestungsanfrageArtikelDetail() { Erstellt am {new Date(artikel.erstellt_am).toLocaleDateString('de-AT')} + {artikel.bevorzugter_lieferant_name && ( + + Bevorzugter Lieferant + {artikel.bevorzugter_lieferant_name} + + )} diff --git a/frontend/src/pages/AusruestungsanfrageDetail.tsx b/frontend/src/pages/AusruestungsanfrageDetail.tsx index 8804909..6cf0d27 100644 --- a/frontend/src/pages/AusruestungsanfrageDetail.tsx +++ b/frontend/src/pages/AusruestungsanfrageDetail.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { Box, Typography, Paper, Button, Chip, IconButton, Table, TableBody, TableCell, TableHead, TableRow, @@ -21,6 +21,7 @@ import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/a import type { AusruestungAnfrage, AusruestungAnfrageDetailResponse, AusruestungAnfrageFormItem, AusruestungAnfrageStatus, + AusruestungEigenschaft, } from '../types/ausruestungsanfrage.types'; // ── Helpers ── @@ -57,6 +58,10 @@ export default function AusruestungsanfrageDetail() { const [adminNotizen, setAdminNotizen] = useState(''); const [statusChangeValue, setStatusChangeValue] = useState(''); + // Eigenschaften state for edit mode + const [editItemEigenschaften, setEditItemEigenschaften] = useState>({}); + const [editItemEigenschaftValues, setEditItemEigenschaftValues] = useState>>({}); + // Permissions const showAdminActions = hasPermission('ausruestungsanfrage:approve'); const canEditAny = hasPermission('ausruestungsanfrage:edit'); @@ -73,9 +78,17 @@ export default function AusruestungsanfrageDetail() { const { data: catalogItems = [] } = useQuery({ queryKey: ['ausruestungsanfrage', 'items-for-edit'], queryFn: () => ausruestungsanfrageApi.getItems({ aktiv: true }), - enabled: editing, + staleTime: 5 * 60 * 1000, }); + const loadEigenschaftenForItem = useCallback(async (artikelId: number) => { + if (editItemEigenschaften[artikelId]) return; + try { + const eigs = await ausruestungsanfrageApi.getArtikelEigenschaften(artikelId); + if (eigs?.length > 0) setEditItemEigenschaften(prev => ({ ...prev, [artikelId]: eigs })); + } catch { /* ignore */ } + }, [editItemEigenschaften]); + // ── Mutations ── const updateMut = useMutation({ mutationFn: (data: { bezeichnung?: string; notizen?: string; items?: AusruestungAnfrageFormItem[] }) => @@ -122,15 +135,31 @@ export default function AusruestungsanfrageDetail() { notizen: p.notizen, eigenschaften: p.eigenschaften?.map(e => ({ eigenschaft_id: e.eigenschaft_id, wert: e.wert })), }))); + const initVals: Record> = {}; + detail.positionen.forEach((p, idx) => { + if (p.artikel_id) loadEigenschaftenForItem(p.artikel_id); + if (p.eigenschaften?.length) { + initVals[idx] = {}; + p.eigenschaften.forEach(e => { initVals[idx][e.eigenschaft_id] = e.wert; }); + } + }); + setEditItemEigenschaftValues(initVals); setEditing(true); }; const handleSaveEdit = () => { if (editItems.length === 0) return; + const items = editItems.map((item, idx) => { + const vals = editItemEigenschaftValues[idx] || {}; + const eigenschaften = Object.entries(vals) + .filter(([, wert]) => wert.trim()) + .map(([eid, wert]) => ({ eigenschaft_id: Number(eid), wert })); + return { ...item, eigenschaften: eigenschaften.length > 0 ? eigenschaften : undefined }; + }); updateMut.mutate({ bezeichnung: editBezeichnung || undefined, notizen: editNotizen || undefined, - items: editItems, + items, }); }; @@ -197,7 +226,8 @@ export default function AusruestungsanfrageDetail() { /> Positionen {editItems.map((item, idx) => ( - + + prev.map((it, i) => i === idx ? { ...it, artikel_id: v.id, bezeichnung: v.bezeichnung } : it)); + loadEigenschaftenForItem(v.id); + setEditItemEigenschaftValues(prev => { const n = { ...prev }; delete n[idx]; return n; }); } }} onInputChange={(_, val, reason) => { @@ -231,6 +263,33 @@ export default function AusruestungsanfrageDetail() { removeEditItem(idx)}> + + {item.artikel_id && editItemEigenschaften[item.artikel_id]?.length > 0 && ( + + {editItemEigenschaften[item.artikel_id].map(e => ( + e.typ === 'options' && e.optionen?.length ? ( + setEditItemEigenschaftValues(prev => ({ + ...prev, [idx]: { ...(prev[idx] || {}), [e.id]: ev.target.value } + }))} + sx={{ minWidth: 140 }} + > + + {e.optionen.map(opt => {opt})} + + ) : ( + setEditItemEigenschaftValues(prev => ({ + ...prev, [idx]: { ...(prev[idx] || {}), [e.id]: ev.target.value } + }))} + sx={{ minWidth: 160 }} + /> + ) + ))} + + )} ))}