catalog search/sort, edit-page characteristics, preferred vendor per article
This commit is contained in:
@@ -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 });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user