catalog search/sort, edit-page characteristics, preferred vendor per article
This commit is contained in:
@@ -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;
|
||||||
@@ -55,26 +55,34 @@ async function deleteKategorie(id: number) {
|
|||||||
// Catalog Items (ausruestung_artikel)
|
// 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 conditions: string[] = [];
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
|
|
||||||
if (filters?.kategorie) {
|
if (filters?.kategorie) {
|
||||||
params.push(filters.kategorie);
|
params.push(filters.kategorie);
|
||||||
conditions.push(`kategorie = $${params.length}`);
|
conditions.push(`aa.kategorie = $${params.length}`);
|
||||||
}
|
}
|
||||||
if (filters?.kategorie_id) {
|
if (filters?.kategorie_id) {
|
||||||
params.push(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) {
|
if (filters?.aktiv !== undefined) {
|
||||||
params.push(filters.aktiv);
|
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 where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
const result = await pool.query(
|
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,
|
params,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -102,7 +110,13 @@ async function getItems(filters?: { kategorie?: string; kategorie_id?: number; a
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getItemById(id: number) {
|
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;
|
if (!result.rows[0]) return null;
|
||||||
|
|
||||||
const row = result.rows[0];
|
const row = result.rows[0];
|
||||||
@@ -137,12 +151,13 @@ async function createItem(
|
|||||||
kategorie_id?: number;
|
kategorie_id?: number;
|
||||||
geschaetzter_preis?: number;
|
geschaetzter_preis?: number;
|
||||||
aktiv?: boolean;
|
aktiv?: boolean;
|
||||||
|
bevorzugter_lieferant_id?: number | null;
|
||||||
},
|
},
|
||||||
userId: string,
|
userId: string,
|
||||||
) {
|
) {
|
||||||
// Build column list dynamically based on whether kategorie_id column exists
|
// Build column list dynamically based on whether kategorie_id column exists
|
||||||
const cols = ['bezeichnung', 'beschreibung', 'kategorie', 'geschaetzter_preis', 'aktiv', 'erstellt_von'];
|
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];
|
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) {
|
if (data.kategorie_id) {
|
||||||
cols.push('kategorie_id');
|
cols.push('kategorie_id');
|
||||||
@@ -166,6 +181,7 @@ async function updateItem(
|
|||||||
kategorie_id?: number | null;
|
kategorie_id?: number | null;
|
||||||
geschaetzter_preis?: number;
|
geschaetzter_preis?: number;
|
||||||
aktiv?: boolean;
|
aktiv?: boolean;
|
||||||
|
bevorzugter_lieferant_id?: number | null;
|
||||||
},
|
},
|
||||||
_userId: string,
|
_userId: string,
|
||||||
) {
|
) {
|
||||||
@@ -196,6 +212,10 @@ async function updateItem(
|
|||||||
params.push(data.aktiv);
|
params.push(data.aktiv);
|
||||||
fields.push(`aktiv = $${params.length}`);
|
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) {
|
if (fields.length === 0) {
|
||||||
return getItemById(id);
|
return getItemById(id);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Box, Tab, Tabs, Typography, Grid, Button, Chip,
|
Box, Tab, Tabs, Typography, Grid, Button, Chip,
|
||||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper,
|
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper,
|
||||||
Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton,
|
Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton,
|
||||||
MenuItem, Divider, Tooltip,
|
MenuItem, Divider, Tooltip, TableSortLabel,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon,
|
Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon,
|
||||||
@@ -166,10 +166,16 @@ function KatalogTab() {
|
|||||||
|
|
||||||
const [filterKategorie, setFilterKategorie] = useState<number | ''>('');
|
const [filterKategorie, setFilterKategorie] = useState<number | ''>('');
|
||||||
const [kategorieDialogOpen, setKategorieDialogOpen] = useState(false);
|
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({
|
const { data: items = [], isLoading } = useQuery({
|
||||||
queryKey: ['ausruestungsanfrage', 'items', filterKategorie],
|
queryKey: ['ausruestungsanfrage', 'items', filterKategorie, search],
|
||||||
queryFn: () => ausruestungsanfrageApi.getItems(filterKategorie ? { kategorie_id: filterKategorie as number } : undefined),
|
queryFn: () => ausruestungsanfrageApi.getItems({
|
||||||
|
...(filterKategorie ? { kategorie_id: filterKategorie as number } : {}),
|
||||||
|
...(search.trim() ? { search: search.trim() } : {}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: kategorien = [] } = useQuery({
|
const { data: kategorien = [] } = useQuery({
|
||||||
@@ -195,9 +201,44 @@ function KatalogTab() {
|
|||||||
onError: () => showError('Fehler beim Loeschen'),
|
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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', gap: 2, mb: 3, alignItems: 'center', flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', gap: 2, mb: 3, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Suche"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
sx={{ minWidth: 200 }}
|
||||||
|
placeholder="Artikel suchen..."
|
||||||
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
select
|
select
|
||||||
size="small"
|
size="small"
|
||||||
@@ -227,14 +268,22 @@ function KatalogTab() {
|
|||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Bezeichnung</TableCell>
|
<TableCell>
|
||||||
<TableCell>Kategorie</TableCell>
|
<TableSortLabel active={sortField === 'bezeichnung'} direction={sortField === 'bezeichnung' ? sortDir : 'asc'} onClick={() => handleSort('bezeichnung')}>
|
||||||
|
Bezeichnung
|
||||||
|
</TableSortLabel>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TableSortLabel active={sortField === 'kategorie'} direction={sortField === 'kategorie' ? sortDir : 'asc'} onClick={() => handleSort('kategorie')}>
|
||||||
|
Kategorie
|
||||||
|
</TableSortLabel>
|
||||||
|
</TableCell>
|
||||||
<TableCell>Beschreibung</TableCell>
|
<TableCell>Beschreibung</TableCell>
|
||||||
{canManage && <TableCell align="right">Aktionen</TableCell>}
|
{canManage && <TableCell align="right">Aktionen</TableCell>}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.map(item => (
|
{sortedItems.map(item => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
hover
|
hover
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useMemo, useCallback } from 'react';
|
|||||||
import {
|
import {
|
||||||
Box, Typography, Paper, Button, TextField, IconButton,
|
Box, Typography, Paper, Button, TextField, IconButton,
|
||||||
Chip, MenuItem, Divider, Checkbox, FormControlLabel, LinearProgress,
|
Chip, MenuItem, Divider, Checkbox, FormControlLabel, LinearProgress,
|
||||||
|
Autocomplete,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
ArrowBack, Edit as EditIcon, Delete as DeleteIcon,
|
ArrowBack, Edit as EditIcon, Delete as DeleteIcon,
|
||||||
@@ -13,6 +14,8 @@ import DashboardLayout from '../components/dashboard/DashboardLayout';
|
|||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
||||||
|
import { bestellungApi } from '../services/bestellung';
|
||||||
|
import type { Lieferant } from '../types/bestellung.types';
|
||||||
import type {
|
import type {
|
||||||
AusruestungArtikel, AusruestungArtikelFormData,
|
AusruestungArtikel, AusruestungArtikelFormData,
|
||||||
AusruestungEigenschaft, AusruestungKategorie,
|
AusruestungEigenschaft, AusruestungKategorie,
|
||||||
@@ -29,6 +32,13 @@ 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
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(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({
|
const { data: eigenschaften = [] } = useQuery({
|
||||||
queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId],
|
queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId],
|
||||||
queryFn: () => ausruestungsanfrageApi.getArtikelEigenschaften(artikelId!),
|
queryFn: () => ausruestungsanfrageApi.getArtikelEigenschaften(artikelId!),
|
||||||
@@ -38,7 +48,14 @@ function EigenschaftenEditor({ artikelId }: { artikelId: number | null }) {
|
|||||||
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),
|
||||||
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'),
|
onError: () => showError('Fehler beim Speichern'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,9 +75,26 @@ function EigenschaftenEditor({ artikelId }: { artikelId: number | null }) {
|
|||||||
pflicht: newPflicht,
|
pflicht: newPflicht,
|
||||||
sort_order: eigenschaften.length,
|
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 <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 hinzufuegen.</Typography>;
|
||||||
@@ -69,12 +103,42 @@ function EigenschaftenEditor({ artikelId }: { artikelId: number | null }) {
|
|||||||
<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={{ display: 'flex', gap: 1, alignItems: 'center', mb: 0.5, pl: 1, borderLeft: '2px solid', borderColor: 'divider' }}>
|
<Box key={e.id} sx={{ mb: 0.5, pl: 1, borderLeft: '2px solid', borderColor: 'divider' }}>
|
||||||
<Typography variant="body2" sx={{ flexGrow: 1 }}>
|
{editingId === e.id ? (
|
||||||
{e.name} ({e.typ === 'options' ? `Auswahl: ${e.optionen?.join(', ')}` : 'Freitext'})
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, p: 1, border: '1px dashed', borderColor: 'divider', borderRadius: 1 }}>
|
||||||
{e.pflicht && <Chip label="Pflicht" size="small" sx={{ ml: 0.5 }} />}
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
</Typography>
|
<TextField size="small" label="Name" value={editName} onChange={ev => setEditName(ev.target.value)} sx={{ flexGrow: 1 }} />
|
||||||
<IconButton size="small" color="error" onClick={() => deleteMut.mutate(e.id)}><DeleteIcon fontSize="small" /></IconButton>
|
<TextField
|
||||||
|
select size="small" label="Typ" value={editTyp}
|
||||||
|
onChange={ev => setEditTyp(ev.target.value as 'options' | 'freitext')}
|
||||||
|
sx={{ minWidth: 120 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="options">Auswahl</MenuItem>
|
||||||
|
<MenuItem value="freitext">Freitext</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox size="small" checked={editPflicht} onChange={ev => setEditPflicht(ev.target.checked)} />}
|
||||||
|
label="Pflicht"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{editTyp === 'options' && (
|
||||||
|
<TextField size="small" label="Optionen (kommagetrennt)" value={editOptionen} onChange={ev => setEditOptionen(ev.target.value)} fullWidth />
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
</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 }}>
|
||||||
@@ -158,6 +222,12 @@ export default function AusruestungsanfrageArtikelDetail() {
|
|||||||
enabled: artikelId != null,
|
enabled: artikelId != null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: lieferanten = [] } = useQuery<Lieferant[]>({
|
||||||
|
queryKey: ['bestellungen', 'lieferanten'],
|
||||||
|
queryFn: () => bestellungApi.getVendors(),
|
||||||
|
enabled: editing || isCreate,
|
||||||
|
});
|
||||||
|
|
||||||
const topKategorien = useMemo(() => kategorien.filter(k => !k.parent_id), [kategorien]);
|
const topKategorien = useMemo(() => kategorien.filter(k => !k.parent_id), [kategorien]);
|
||||||
const subKategorienOf = useCallback((parentId: number) => kategorien.filter(k => k.parent_id === parentId), [kategorien]);
|
const subKategorienOf = useCallback((parentId: number) => kategorien.filter(k => k.parent_id === parentId), [kategorien]);
|
||||||
const subKats = useMemo(() => mainKat ? subKategorienOf(mainKat as number) : [], [mainKat, subKategorienOf]);
|
const subKats = useMemo(() => mainKat ? subKategorienOf(mainKat as number) : [], [mainKat, subKategorienOf]);
|
||||||
@@ -213,6 +283,7 @@ export default function AusruestungsanfrageArtikelDetail() {
|
|||||||
bezeichnung: artikel.bezeichnung,
|
bezeichnung: artikel.bezeichnung,
|
||||||
beschreibung: artikel.beschreibung,
|
beschreibung: artikel.beschreibung,
|
||||||
kategorie_id: artikel.kategorie_id ?? null,
|
kategorie_id: artikel.kategorie_id ?? null,
|
||||||
|
bevorzugter_lieferant_id: artikel.bevorzugter_lieferant_id ?? null,
|
||||||
});
|
});
|
||||||
const kat = kategorien.find(k => k.id === artikel.kategorie_id);
|
const kat = kategorien.find(k => k.id === artikel.kategorie_id);
|
||||||
if (kat?.parent_id) {
|
if (kat?.parent_id) {
|
||||||
@@ -321,6 +392,15 @@ export default function AusruestungsanfrageArtikelDetail() {
|
|||||||
</TextField>
|
</TextField>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Autocomplete
|
||||||
|
options={lieferanten}
|
||||||
|
getOptionLabel={l => 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 => <TextField {...params} label="Bevorzugter Lieferant (optional)" />}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
{canManage && <EigenschaftenEditor artikelId={artikelId} />}
|
{canManage && <EigenschaftenEditor artikelId={artikelId} />}
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -363,6 +443,12 @@ export default function AusruestungsanfrageArtikelDetail() {
|
|||||||
<Typography variant="caption" color="text.secondary">Erstellt am</Typography>
|
<Typography variant="caption" color="text.secondary">Erstellt am</Typography>
|
||||||
<Typography variant="body2">{new Date(artikel.erstellt_am).toLocaleDateString('de-AT')}</Typography>
|
<Typography variant="body2">{new Date(artikel.erstellt_am).toLocaleDateString('de-AT')}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
{artikel.bevorzugter_lieferant_name && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">Bevorzugter Lieferant</Typography>
|
||||||
|
<Typography variant="body2">{artikel.bevorzugter_lieferant_name}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Box, Typography, Paper, Button, Chip, IconButton,
|
Box, Typography, Paper, Button, Chip, IconButton,
|
||||||
Table, TableBody, TableCell, TableHead, TableRow,
|
Table, TableBody, TableCell, TableHead, TableRow,
|
||||||
@@ -21,6 +21,7 @@ import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/a
|
|||||||
import type {
|
import type {
|
||||||
AusruestungAnfrage, AusruestungAnfrageDetailResponse,
|
AusruestungAnfrage, AusruestungAnfrageDetailResponse,
|
||||||
AusruestungAnfrageFormItem, AusruestungAnfrageStatus,
|
AusruestungAnfrageFormItem, AusruestungAnfrageStatus,
|
||||||
|
AusruestungEigenschaft,
|
||||||
} from '../types/ausruestungsanfrage.types';
|
} from '../types/ausruestungsanfrage.types';
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
@@ -57,6 +58,10 @@ export default function AusruestungsanfrageDetail() {
|
|||||||
const [adminNotizen, setAdminNotizen] = useState('');
|
const [adminNotizen, setAdminNotizen] = useState('');
|
||||||
const [statusChangeValue, setStatusChangeValue] = useState('');
|
const [statusChangeValue, setStatusChangeValue] = useState('');
|
||||||
|
|
||||||
|
// Eigenschaften state for edit mode
|
||||||
|
const [editItemEigenschaften, setEditItemEigenschaften] = useState<Record<number, AusruestungEigenschaft[]>>({});
|
||||||
|
const [editItemEigenschaftValues, setEditItemEigenschaftValues] = useState<Record<number, Record<number, string>>>({});
|
||||||
|
|
||||||
// Permissions
|
// Permissions
|
||||||
const showAdminActions = hasPermission('ausruestungsanfrage:approve');
|
const showAdminActions = hasPermission('ausruestungsanfrage:approve');
|
||||||
const canEditAny = hasPermission('ausruestungsanfrage:edit');
|
const canEditAny = hasPermission('ausruestungsanfrage:edit');
|
||||||
@@ -73,9 +78,17 @@ export default function AusruestungsanfrageDetail() {
|
|||||||
const { data: catalogItems = [] } = useQuery({
|
const { data: catalogItems = [] } = useQuery({
|
||||||
queryKey: ['ausruestungsanfrage', 'items-for-edit'],
|
queryKey: ['ausruestungsanfrage', 'items-for-edit'],
|
||||||
queryFn: () => ausruestungsanfrageApi.getItems({ aktiv: true }),
|
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 ──
|
// ── Mutations ──
|
||||||
const updateMut = useMutation({
|
const updateMut = useMutation({
|
||||||
mutationFn: (data: { bezeichnung?: string; notizen?: string; items?: AusruestungAnfrageFormItem[] }) =>
|
mutationFn: (data: { bezeichnung?: string; notizen?: string; items?: AusruestungAnfrageFormItem[] }) =>
|
||||||
@@ -122,15 +135,31 @@ export default function AusruestungsanfrageDetail() {
|
|||||||
notizen: p.notizen,
|
notizen: p.notizen,
|
||||||
eigenschaften: p.eigenschaften?.map(e => ({ eigenschaft_id: e.eigenschaft_id, wert: e.wert })),
|
eigenschaften: p.eigenschaften?.map(e => ({ eigenschaft_id: e.eigenschaft_id, wert: e.wert })),
|
||||||
})));
|
})));
|
||||||
|
const initVals: Record<number, Record<number, string>> = {};
|
||||||
|
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);
|
setEditing(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveEdit = () => {
|
const handleSaveEdit = () => {
|
||||||
if (editItems.length === 0) return;
|
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({
|
updateMut.mutate({
|
||||||
bezeichnung: editBezeichnung || undefined,
|
bezeichnung: editBezeichnung || undefined,
|
||||||
notizen: editNotizen || undefined,
|
notizen: editNotizen || undefined,
|
||||||
items: editItems,
|
items,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -197,7 +226,8 @@ export default function AusruestungsanfrageDetail() {
|
|||||||
/>
|
/>
|
||||||
<Typography variant="subtitle2" sx={{ mt: 1 }}>Positionen</Typography>
|
<Typography variant="subtitle2" sx={{ mt: 1 }}>Positionen</Typography>
|
||||||
{editItems.map((item, idx) => (
|
{editItems.map((item, idx) => (
|
||||||
<Box key={idx} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
<Box key={idx} sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
freeSolo
|
freeSolo
|
||||||
options={catalogItems}
|
options={catalogItems}
|
||||||
@@ -209,6 +239,8 @@ export default function AusruestungsanfrageDetail() {
|
|||||||
updateEditItem(idx, 'artikel_id', undefined);
|
updateEditItem(idx, 'artikel_id', undefined);
|
||||||
} else if (v) {
|
} else if (v) {
|
||||||
setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, artikel_id: v.id, bezeichnung: v.bezeichnung } : it));
|
setEditItems(prev => 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) => {
|
onInputChange={(_, val, reason) => {
|
||||||
@@ -231,6 +263,33 @@ export default function AusruestungsanfrageDetail() {
|
|||||||
<IconButton size="small" onClick={() => removeEditItem(idx)}>
|
<IconButton size="small" onClick={() => removeEditItem(idx)}>
|
||||||
<DeleteIcon fontSize="small" />
|
<DeleteIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
{item.artikel_id && editItemEigenschaften[item.artikel_id]?.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, ml: 1, pl: 1.5, borderLeft: '2px solid', borderColor: 'divider' }}>
|
||||||
|
{editItemEigenschaften[item.artikel_id].map(e => (
|
||||||
|
e.typ === 'options' && e.optionen?.length ? (
|
||||||
|
<TextField key={e.id} select size="small" label={e.name} required={e.pflicht}
|
||||||
|
value={editItemEigenschaftValues[idx]?.[e.id] || ''}
|
||||||
|
onChange={ev => setEditItemEigenschaftValues(prev => ({
|
||||||
|
...prev, [idx]: { ...(prev[idx] || {}), [e.id]: ev.target.value }
|
||||||
|
}))}
|
||||||
|
sx={{ minWidth: 140 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">—</MenuItem>
|
||||||
|
{e.optionen.map(opt => <MenuItem key={opt} value={opt}>{opt}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
) : (
|
||||||
|
<TextField key={e.id} size="small" label={e.name} required={e.pflicht}
|
||||||
|
value={editItemEigenschaftValues[idx]?.[e.id] || ''}
|
||||||
|
onChange={ev => setEditItemEigenschaftValues(prev => ({
|
||||||
|
...prev, [idx]: { ...(prev[idx] || {}), [e.id]: ev.target.value }
|
||||||
|
}))}
|
||||||
|
sx={{ minWidth: 160 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
<Button size="small" startIcon={<AddIcon />} onClick={addEditItem}>
|
<Button size="small" startIcon={<AddIcon />} onClick={addEditItem}>
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ export default function AusruestungsanfrageZuBestellung() {
|
|||||||
queryFn: () => bestellungApi.getVendors(),
|
queryFn: () => bestellungApi.getVendors(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: catalogItems = [] } = useQuery({
|
||||||
|
queryKey: ['ausruestungsanfrage', 'items-for-edit'],
|
||||||
|
queryFn: () => ausruestungsanfrageApi.getItems({ aktiv: true }),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
const anfrage = detail?.anfrage;
|
const anfrage = detail?.anfrage;
|
||||||
const positionen: AusruestungAnfragePosition[] = detail?.positionen ?? [];
|
const positionen: AusruestungAnfragePosition[] = detail?.positionen ?? [];
|
||||||
|
|
||||||
@@ -88,6 +94,27 @@ export default function AusruestungsanfrageZuBestellung() {
|
|||||||
// Track which position triggered the new-vendor dialog
|
// Track which position triggered the new-vendor dialog
|
||||||
const [newVendorTargetPosId, setNewVendorTargetPosId] = useState<number | null>(null);
|
const [newVendorTargetPosId, setNewVendorTargetPosId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// ── Pre-populate vendor assignments from catalog items' preferred vendor ──
|
||||||
|
useEffect(() => {
|
||||||
|
if (positionen.length === 0 || catalogItems.length === 0 || vendors.length === 0) return;
|
||||||
|
setAssignments(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
let changed = false;
|
||||||
|
positionen.forEach(pos => {
|
||||||
|
// Don't overwrite existing manual assignments
|
||||||
|
if (next[pos.id] != null) return;
|
||||||
|
if (!pos.artikel_id) return;
|
||||||
|
const artikel = catalogItems.find(a => a.id === pos.artikel_id);
|
||||||
|
if (!artikel?.bevorzugter_lieferant_id) return;
|
||||||
|
const vendor = vendors.find(v => v.id === artikel.bevorzugter_lieferant_id);
|
||||||
|
if (!vendor) return;
|
||||||
|
next[pos.id] = { lieferantId: vendor.id, lieferantName: vendor.name };
|
||||||
|
changed = true;
|
||||||
|
});
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, [positionen, catalogItems, vendors]);
|
||||||
|
|
||||||
// ── Derived: vendor groups ──
|
// ── Derived: vendor groups ──
|
||||||
const vendorGroups: VendorGroup[] = useMemo(() => {
|
const vendorGroups: VendorGroup[] = useMemo(() => {
|
||||||
const map = new Map<number, VendorGroup>();
|
const map = new Map<number, VendorGroup>();
|
||||||
|
|||||||
@@ -32,11 +32,12 @@ export const ausruestungsanfrageApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Catalog Items ──
|
// ── Catalog Items ──
|
||||||
getItems: async (filters?: { kategorie?: string; kategorie_id?: number; aktiv?: boolean }): Promise<AusruestungArtikel[]> => {
|
getItems: async (filters?: { kategorie?: string; kategorie_id?: number; aktiv?: boolean; search?: string }): Promise<AusruestungArtikel[]> => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (filters?.kategorie) params.set('kategorie', filters.kategorie);
|
if (filters?.kategorie) params.set('kategorie', filters.kategorie);
|
||||||
if (filters?.kategorie_id) params.set('kategorie_id', String(filters.kategorie_id));
|
if (filters?.kategorie_id) params.set('kategorie_id', String(filters.kategorie_id));
|
||||||
if (filters?.aktiv !== undefined) params.set('aktiv', String(filters.aktiv));
|
if (filters?.aktiv !== undefined) params.set('aktiv', String(filters.aktiv));
|
||||||
|
if (filters?.search) params.append('search', filters.search);
|
||||||
const r = await api.get(`/api/ausruestungsanfragen/items?${params.toString()}`);
|
const r = await api.get(`/api/ausruestungsanfragen/items?${params.toString()}`);
|
||||||
return r.data.data;
|
return r.data.data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export interface AusruestungArtikel {
|
|||||||
aktualisiert_am: string;
|
aktualisiert_am: string;
|
||||||
eigenschaften_count?: number;
|
eigenschaften_count?: number;
|
||||||
eigenschaften?: AusruestungEigenschaft[];
|
eigenschaften?: AusruestungEigenschaft[];
|
||||||
|
bevorzugter_lieferant_id?: number | null;
|
||||||
|
bevorzugter_lieferant_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AusruestungArtikelFormData {
|
export interface AusruestungArtikelFormData {
|
||||||
@@ -53,6 +55,7 @@ export interface AusruestungArtikelFormData {
|
|||||||
kategorie_id?: number | null;
|
kategorie_id?: number | null;
|
||||||
geschaetzter_preis?: number;
|
geschaetzter_preis?: number;
|
||||||
aktiv?: boolean;
|
aktiv?: boolean;
|
||||||
|
bevorzugter_lieferant_id?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Requests ──
|
// ── Requests ──
|
||||||
|
|||||||
Reference in New Issue
Block a user