catalog search/sort, edit-page characteristics, preferred vendor per article

This commit is contained in:
Matthias Hochmeister
2026-03-27 13:45:13 +01:00
parent 35b3718e38
commit 6885cba3be
3 changed files with 55 additions and 83 deletions

View File

@@ -81,7 +81,8 @@ class AusruestungsanfrageController {
const kategorie = req.query.kategorie as string | undefined; const kategorie = req.query.kategorie as string | undefined;
const kategorie_id = req.query.kategorie_id ? Number(req.query.kategorie_id) : undefined; const kategorie_id = req.query.kategorie_id ? Number(req.query.kategorie_id) : undefined;
const aktiv = req.query.aktiv !== undefined ? req.query.aktiv === 'true' : undefined; const aktiv = req.query.aktiv !== undefined ? req.query.aktiv === 'true' : undefined;
const items = await ausruestungsanfrageService.getItems({ kategorie, kategorie_id, aktiv }); const search = req.query.search as string | undefined;
const items = await ausruestungsanfrageService.getItems({ kategorie, kategorie_id, aktiv, search });
res.status(200).json({ success: true, data: items }); res.status(200).json({ success: true, data: items });
} catch (error) { } catch (error) {
logger.error('AusruestungsanfrageController.getItems error', { error }); logger.error('AusruestungsanfrageController.getItems error', { error });

View File

@@ -9,7 +9,7 @@ import {
Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon,
Check as CheckIcon, Close as CloseIcon, Settings as SettingsIcon, Check as CheckIcon, Close as CloseIcon, Settings as SettingsIcon,
} 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 { useSearchParams, useNavigate } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab'; import ChatAwareFab from '../components/shared/ChatAwareFab';
@@ -170,12 +170,13 @@ function KatalogTab() {
const [sortField, setSortField] = useState<'bezeichnung' | 'kategorie'>('bezeichnung'); const [sortField, setSortField] = useState<'bezeichnung' | 'kategorie'>('bezeichnung');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const { data: items = [], isLoading } = useQuery({ const { data: items = [], isLoading, isFetching } = useQuery({
queryKey: ['ausruestungsanfrage', 'items', filterKategorie, search], queryKey: ['ausruestungsanfrage', 'items', filterKategorie, search],
queryFn: () => ausruestungsanfrageApi.getItems({ queryFn: () => ausruestungsanfrageApi.getItems({
...(filterKategorie ? { kategorie_id: filterKategorie as number } : {}), ...(filterKategorie ? { kategorie_id: filterKategorie as number } : {}),
...(search.trim() ? { search: search.trim() } : {}), ...(search.trim() ? { search: search.trim() } : {}),
}), }),
placeholderData: keepPreviousData,
}); });
const { data: kategorien = [] } = useQuery({ const { data: kategorien = [] } = useQuery({
@@ -261,10 +262,13 @@ function KatalogTab() {
{isLoading ? ( {isLoading ? (
<Typography color="text.secondary">Lade Katalog...</Typography> <Typography color="text.secondary">Lade Katalog...</Typography>
) : items.length === 0 ? ( ) : items.length === 0 && !isFetching ? (
<Typography color="text.secondary">Keine Artikel vorhanden.</Typography> <Typography color="text.secondary">Keine Artikel vorhanden.</Typography>
) : ( ) : (
<TableContainer component={Paper} variant="outlined"> <TableContainer component={Paper} variant="outlined" sx={{ opacity: isFetching ? 0.6 : 1, transition: 'opacity 150ms' }}>
<Typography color="text.secondary">Keine Artikel vorhanden.</Typography>
) : (
<TableContainer component={Paper} variant="outlined" sx={{ opacity: isFetching ? 0.6 : 1, transition: 'opacity 150ms' }}>
<Table size="small"> <Table size="small">
<TableHead> <TableHead>
<TableRow> <TableRow>

View File

@@ -5,7 +5,7 @@ import {
Autocomplete, Autocomplete,
} from '@mui/material'; } from '@mui/material';
import { import {
ArrowBack, Edit as EditIcon, Delete as DeleteIcon, ArrowBack, Delete as DeleteIcon,
Add as AddIcon, Add as AddIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -32,12 +32,8 @@ function EigenschaftenEditor({ artikelId }: { artikelId: number | null }) {
const [newOptionen, setNewOptionen] = useState(''); const [newOptionen, setNewOptionen] = useState('');
const [newPflicht, setNewPflicht] = useState(false); const [newPflicht, setNewPflicht] = useState(false);
// Inline edit state // Per-eigenschaft edit state
const [editingId, setEditingId] = useState<number | null>(null); const [rowState, setRowState] = useState<Record<number, { name: string; typ: 'options' | 'freitext'; optionen: string; pflicht: boolean }>>({});
const [editName, setEditName] = useState('');
const [editTyp, setEditTyp] = useState<'options' | 'freitext'>('options');
const [editOptionen, setEditOptionen] = useState('');
const [editPflicht, setEditPflicht] = useState(false);
const { data: eigenschaften = [] } = useQuery({ const { data: eigenschaften = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId], queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId],
@@ -45,6 +41,14 @@ function EigenschaftenEditor({ artikelId }: { artikelId: number | null }) {
enabled: artikelId != null, enabled: artikelId != null,
}); });
const getRow = (e: AusruestungEigenschaft) =>
rowState[e.id] ?? { name: e.name, typ: e.typ, optionen: e.optionen?.join(', ') || '', pflicht: e.pflicht };
const updateRow = (e: AusruestungEigenschaft, patch: Partial<{ name: string; typ: 'options' | 'freitext'; optionen: string; pflicht: boolean }>) => {
const current = rowState[e.id] ?? { name: e.name, typ: e.typ, optionen: e.optionen?.join(', ') || '', pflicht: e.pflicht };
setRowState(prev => ({ ...prev, [e.id]: { ...current, ...patch } }));
};
const upsertMut = useMutation({ const upsertMut = useMutation({
mutationFn: (data: { eigenschaft_id?: number; name: string; typ: string; optionen?: string[]; pflicht?: boolean; sort_order?: number }) => mutationFn: (data: { eigenschaft_id?: number; name: string; typ: string; optionen?: string[]; pflicht?: boolean; sort_order?: number }) =>
ausruestungsanfrageApi.upsertArtikelEigenschaft(artikelId!, data), ausruestungsanfrageApi.upsertArtikelEigenschaft(artikelId!, data),
@@ -54,101 +58,76 @@ function EigenschaftenEditor({ artikelId }: { artikelId: number | null }) {
setNewName(''); setNewName('');
setNewOptionen(''); setNewOptionen('');
setNewPflicht(false); setNewPflicht(false);
setEditingId(null);
}, },
onError: () => showError('Fehler beim Speichern'), onError: () => showError('Fehler beim Speichern'),
}); });
const deleteMut = useMutation({ const deleteMut = useMutation({
mutationFn: (id: number) => ausruestungsanfrageApi.deleteArtikelEigenschaft(id), mutationFn: (id: number) => ausruestungsanfrageApi.deleteArtikelEigenschaft(id),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId] }); showSuccess('Eigenschaft geloescht'); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId] }); showSuccess('Eigenschaft gelöscht'); },
onError: () => showError('Fehler beim Loeschen'), onError: () => showError('Fehler beim Löschen'),
}); });
const handleAdd = () => { const handleAdd = () => {
if (!newName.trim()) return; if (!newName.trim()) return;
const optionen = newTyp === 'options' ? newOptionen.split(',').map(s => s.trim()).filter(Boolean) : undefined; const optionen = newTyp === 'options' ? newOptionen.split(',').map(s => s.trim()).filter(Boolean) : undefined;
upsertMut.mutate({ upsertMut.mutate({ name: newName.trim(), typ: newTyp, optionen, pflicht: newPflicht, sort_order: eigenschaften.length });
name: newName.trim(),
typ: newTyp,
optionen,
pflicht: newPflicht,
sort_order: eigenschaften.length,
});
}; };
const startEditEigenschaft = (e: AusruestungEigenschaft) => { const handleSaveRow = (e: AusruestungEigenschaft) => {
setEditingId(e.id); const row = getRow(e);
setEditName(e.name); if (!row.name.trim()) return;
setEditTyp(e.typ);
setEditOptionen(e.optionen?.join(', ') || '');
setEditPflicht(e.pflicht);
};
const handleSaveEdit = (e: AusruestungEigenschaft) => {
if (!editName.trim()) return;
upsertMut.mutate({ upsertMut.mutate({
eigenschaft_id: e.id, eigenschaft_id: e.id,
name: editName.trim(), name: row.name.trim(),
typ: editTyp, typ: row.typ,
optionen: editTyp === 'options' ? editOptionen.split(',').map(s => s.trim()).filter(Boolean) : undefined, optionen: row.typ === 'options' ? row.optionen.split(',').map(s => s.trim()).filter(Boolean) : undefined,
pflicht: editPflicht, pflicht: row.pflicht,
sort_order: e.sort_order, sort_order: e.sort_order,
}); });
}; };
if (artikelId == null) return <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>Bitte speichern Sie den Artikel zuerst, bevor Sie Eigenschaften hinzufuegen.</Typography>; if (artikelId == null) return <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>Bitte speichern Sie den Artikel zuerst, bevor Sie Eigenschaften hinzufügen.</Typography>;
return ( return (
<Box sx={{ mt: 1 }}> <Box sx={{ mt: 1 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Eigenschaften</Typography> <Typography variant="subtitle2" sx={{ mb: 1 }}>Eigenschaften</Typography>
{eigenschaften.map(e => ( {eigenschaften.map(e => {
<Box key={e.id} sx={{ mb: 0.5, pl: 1, borderLeft: '2px solid', borderColor: 'divider' }}> const row = getRow(e);
{editingId === e.id ? ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, p: 1, border: '1px dashed', borderColor: 'divider', borderRadius: 1 }}> <Box key={e.id} sx={{ mb: 1, pl: 1, borderLeft: '2px solid', borderColor: 'divider' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, p: 1 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}> <Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField size="small" label="Name" value={editName} onChange={ev => setEditName(ev.target.value)} sx={{ flexGrow: 1 }} /> <TextField size="small" label="Name" value={row.name} onChange={ev => updateRow(e, { name: ev.target.value })} sx={{ flexGrow: 1 }} />
<TextField <TextField
select size="small" label="Typ" value={editTyp} select size="small" label="Typ" value={row.typ}
onChange={ev => setEditTyp(ev.target.value as 'options' | 'freitext')} onChange={ev => updateRow(e, { typ: ev.target.value as 'options' | 'freitext' })}
sx={{ minWidth: 120 }} sx={{ minWidth: 120 }}
> >
<MenuItem value="options">Auswahl</MenuItem> <MenuItem value="options">Auswahl</MenuItem>
<MenuItem value="freitext">Freitext</MenuItem> <MenuItem value="freitext">Freitext</MenuItem>
</TextField> </TextField>
<FormControlLabel <FormControlLabel
control={<Checkbox size="small" checked={editPflicht} onChange={ev => setEditPflicht(ev.target.checked)} />} control={<Checkbox size="small" checked={row.pflicht} onChange={ev => updateRow(e, { pflicht: ev.target.checked })} />}
label="Pflicht" label="Pflicht"
/> />
</Box> <Button size="small" variant="outlined" onClick={() => handleSaveRow(e)} disabled={!row.name.trim() || upsertMut.isPending}>
{editTyp === 'options' && ( Speichern
<TextField size="small" label="Optionen (kommagetrennt)" value={editOptionen} onChange={ev => setEditOptionen(ev.target.value)} fullWidth /> </Button>
)}
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Button size="small" variant="contained" onClick={() => handleSaveEdit(e)} disabled={!editName.trim() || upsertMut.isPending}>Speichern</Button>
<Button size="small" onClick={() => setEditingId(null)}>Abbrechen</Button>
</Box>
</Box>
) : (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography variant="body2" sx={{ flexGrow: 1 }}>
{e.name} ({e.typ === 'options' ? `Auswahl: ${e.optionen?.join(', ')}` : 'Freitext'})
{e.pflicht && <Chip label="Pflicht" size="small" sx={{ ml: 0.5 }} />}
</Typography>
<IconButton size="small" onClick={() => startEditEigenschaft(e)}><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deleteMut.mutate(e.id)}><DeleteIcon fontSize="small" /></IconButton> <IconButton size="small" color="error" onClick={() => deleteMut.mutate(e.id)}><DeleteIcon fontSize="small" /></IconButton>
</Box> </Box>
{row.typ === 'options' && (
<TextField size="small" label="Optionen (kommagetrennt)" value={row.optionen} onChange={ev => updateRow(e, { optionen: ev.target.value })} fullWidth />
)} )}
</Box> </Box>
))} </Box>
);
})}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1, p: 1, border: '1px dashed', borderColor: 'divider', borderRadius: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1, p: 1, border: '1px dashed', borderColor: 'divider', borderRadius: 1 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}> <Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField size="small" label="Name" value={newName} onChange={e => setNewName(e.target.value)} sx={{ flexGrow: 1 }} /> <TextField size="small" label="Name" value={newName} onChange={e => setNewName(e.target.value)} sx={{ flexGrow: 1 }} />
<TextField <TextField
select select size="small" label="Typ" value={newTyp}
size="small"
label="Typ"
value={newTyp}
onChange={e => setNewTyp(e.target.value as 'options' | 'freitext')} onChange={e => setNewTyp(e.target.value as 'options' | 'freitext')}
sx={{ minWidth: 120 }} sx={{ minWidth: 120 }}
> >
@@ -161,22 +140,10 @@ function EigenschaftenEditor({ artikelId }: { artikelId: number | null }) {
/> />
</Box> </Box>
{newTyp === 'options' && ( {newTyp === 'options' && (
<TextField <TextField size="small" label="Optionen (kommagetrennt)" value={newOptionen} onChange={e => setNewOptionen(e.target.value)} placeholder="S, M, L, XL" fullWidth />
size="small"
label="Optionen (kommagetrennt)"
value={newOptionen}
onChange={e => setNewOptionen(e.target.value)}
placeholder="S, M, L, XL"
fullWidth
/>
)} )}
<Button <Button size="small" startIcon={<AddIcon />} onClick={handleAdd} disabled={!newName.trim() || upsertMut.isPending}>
size="small" Eigenschaft hinzufügen
startIcon={<AddIcon />}
onClick={handleAdd}
disabled={!newName.trim() || upsertMut.isPending}
>
Eigenschaft hinzufuegen
</Button> </Button>
</Box> </Box>
</Box> </Box>
@@ -369,7 +336,7 @@ export default function AusruestungsanfrageArtikelDetail() {
setMainKat(val); setMainKat(val);
if (val) { if (val) {
const subs = subKategorienOf(val as number); const subs = subKategorienOf(val as number);
setForm(f => ({ ...f, kategorie_id: subs.length === 0 ? (val as number) : null })); setForm(f => ({ ...f, kategorie_id: val as number }));
} else { } else {
setForm(f => ({ ...f, kategorie_id: null })); setForm(f => ({ ...f, kategorie_id: null }));
} }