shared catalog in Bestellungen, catalog picker in line items, Ersatzbeschaffung flag, vendor detail flash fix

This commit is contained in:
Matthias Hochmeister
2026-03-27 14:50:31 +01:00
parent c704e2c173
commit 29d66e37a1
16 changed files with 506 additions and 32 deletions

View File

@@ -4,7 +4,7 @@ import {
Table, TableBody, TableCell, TableHead, TableRow,
Dialog, DialogTitle, DialogContent, DialogActions, TextField,
MenuItem, Select, FormControl, InputLabel, Autocomplete,
Checkbox, LinearProgress,
Checkbox, LinearProgress, Switch, FormControlLabel, Alert,
} from '@mui/material';
import {
ArrowBack, Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon,
@@ -123,6 +123,15 @@ export default function AusruestungsanfrageDetail() {
onError: () => showError('Fehler beim Aktualisieren'),
});
const zurueckgegebenMut = useMutation({
mutationFn: ({ positionId, zurueckgegeben }: { positionId: number; zurueckgegeben: boolean }) =>
ausruestungsanfrageApi.updatePositionZurueckgegeben(positionId, zurueckgegeben),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
},
onError: () => showError('Fehler beim Aktualisieren'),
});
// ── Edit helpers ──
const startEditing = () => {
if (!detail) return;
@@ -134,6 +143,7 @@ export default function AusruestungsanfrageDetail() {
menge: p.menge,
notizen: p.notizen,
eigenschaften: p.eigenschaften?.map(e => ({ eigenschaft_id: e.eigenschaft_id, wert: e.wert })),
ist_ersatz: p.ist_ersatz || false,
})));
const initVals: Record<number, Record<number, string>> = {};
detail.positionen.forEach((p, idx) => {
@@ -290,6 +300,23 @@ export default function AusruestungsanfrageDetail() {
))}
</Box>
)}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, ml: 1 }}>
<FormControlLabel
control={
<Switch
size="small"
checked={item.ist_ersatz || false}
onChange={(e) => updateEditItem(idx, 'ist_ersatz', e.target.checked)}
/>
}
label="Ersatzbeschaffung"
/>
{item.ist_ersatz && (
<Alert severity="info" sx={{ py: 0, fontSize: '0.8rem' }}>
Altes Gerät muss zurückgegeben werden
</Alert>
)}
</Box>
</Box>
))}
<Button size="small" startIcon={<AddIcon />} onClick={addEditItem}>
@@ -376,19 +403,33 @@ export default function AusruestungsanfrageDetail() {
)}
<TableCell>
<Typography variant="body2" fontWeight={500} sx={p.geliefert ? { textDecoration: 'line-through' } : undefined}>{p.bezeichnung}</Typography>
{p.eigenschaften && p.eigenschaften.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 0.5 }}>
{p.eigenschaften.map(e => (
<Chip key={e.eigenschaft_id} label={`${e.eigenschaft_name}: ${e.wert}`} size="small" variant="outlined" />
))}
</Box>
)}
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 0.5 }}>
{p.ist_ersatz && (
<Chip label="Ersatzbeschaffung" size="small" color="warning" variant="outlined" />
)}
{p.eigenschaften && p.eigenschaften.length > 0 && p.eigenschaften.map(e => (
<Chip key={e.eigenschaft_id} label={`${e.eigenschaft_name}: ${e.wert}`} size="small" variant="outlined" />
))}
</Box>
</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight={600}>{p.menge}x</Typography>
</TableCell>
<TableCell>
{p.notizen && <Typography variant="caption" color="text.secondary">{p.notizen}</Typography>}
{p.notizen && <Typography variant="caption" color="text.secondary" display="block">{p.notizen}</Typography>}
{p.ist_ersatz && (
<FormControlLabel
control={
<Checkbox
size="small"
checked={p.altes_geraet_zurueckgegeben}
disabled={!showAdminActions || zurueckgegebenMut.isPending}
onChange={(_, checked) => zurueckgegebenMut.mutate({ positionId: p.id, zurueckgegeben: checked })}
/>
}
label={<Typography variant="caption">Altes Gerät zurückgegeben</Typography>}
/>
)}
</TableCell>
</TableRow>
))}

View File

@@ -194,6 +194,10 @@ export default function AusruestungsanfrageZuBestellung() {
menge: p.menge,
einheit: p.einheit,
notizen: p.notizen,
artikel_id: p.artikel_id,
spezifikationen: p.eigenschaften?.length
? p.eigenschaften.map(e => `${e.eigenschaft_name}: ${e.wert}`)
: undefined,
})),
}));
createOrdersMut.mutate({ orders });

View File

@@ -51,10 +51,12 @@ import GermanDateField from '../components/shared/GermanDateField';
import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { bestellungApi } from '../services/bestellung';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import { configApi } from '../services/config';
import { addPdfHeader, addPdfFooter } from '../utils/pdfExport';
import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types';
import type { BestellungStatus, BestellpositionFormData, ErinnerungFormData } from '../types/bestellung.types';
import type { AusruestungArtikel, AusruestungEigenschaft } from '../types/ausruestungsanfrage.types';
// ── Helpers ──
@@ -159,6 +161,11 @@ export default function BestellungDetail() {
const [reminderFormOpen, setReminderFormOpen] = useState(false);
const [deleteReminderTarget, setDeleteReminderTarget] = useState<number | null>(null);
// ── Catalog picker state ──
const [selectedKatalogItem, setSelectedKatalogItem] = useState<AusruestungArtikel | null>(null);
const [katalogEigenschaften, setKatalogEigenschaften] = useState<AusruestungEigenschaft[]>([]);
const [eigenschaftValues, setEigenschaftValues] = useState<Record<number, string>>({});
// ── Query ──
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ['bestellung', orderId],
@@ -184,6 +191,13 @@ export default function BestellungDetail() {
enabled: editMode,
});
const { data: katalogItems = [] } = useQuery({
queryKey: ['katalogItems'],
queryFn: () => bestellungApi.getKatalogItems(),
enabled: editMode,
staleTime: 5 * 60 * 1000,
});
const canCreate = hasPermission('bestellungen:create');
const canDelete = hasPermission('bestellungen:delete');
const canManageReminders = hasPermission('bestellungen:manage_reminders');
@@ -214,6 +228,9 @@ export default function BestellungDetail() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
setNewItem({ ...emptyItem });
setSelectedKatalogItem(null);
setKatalogEigenschaften([]);
setEigenschaftValues({});
showSuccess('Position hinzugefügt');
},
onError: () => showError('Fehler beim Hinzufügen der Position'),
@@ -348,7 +365,21 @@ export default function BestellungDetail() {
function handleAddItem() {
if (!newItem.bezeichnung.trim()) return;
addItem.mutate(newItem);
// Merge characteristic values into spezifikationen
const charSpecs = Object.entries(eigenschaftValues)
.filter(([, v]) => v.trim())
.map(([eid, v]) => {
const eig = katalogEigenschaften.find(e => e.id === Number(eid));
return eig ? `${eig.name}: ${v}` : v;
});
const mergedSpecs = [...(newItem.spezifikationen || []), ...charSpecs];
addItem.mutate({
...newItem,
spezifikationen: mergedSpecs.length > 0 ? mergedSpecs : undefined,
});
setSelectedKatalogItem(null);
setKatalogEigenschaften([]);
setEigenschaftValues({});
}
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
@@ -968,9 +999,52 @@ export default function BestellungDetail() {
{/* ── Add Item Row ── */}
{editMode && canCreate && (
<>
<TableRow>
<TableCell>
<TextField size="small" placeholder="Bezeichnung" value={newItem.bezeichnung} onChange={(e) => setNewItem((f) => ({ ...f, bezeichnung: e.target.value }))} />
<Autocomplete<AusruestungArtikel, false, false, true>
freeSolo
size="small"
options={katalogItems}
getOptionLabel={(o) => typeof o === 'string' ? o : o.bezeichnung}
value={selectedKatalogItem || newItem.bezeichnung || ''}
onChange={async (_, v) => {
if (typeof v === 'string') {
setNewItem((f) => ({ ...f, bezeichnung: v, artikel_id: undefined }));
setSelectedKatalogItem(null);
setKatalogEigenschaften([]);
setEigenschaftValues({});
} else if (v) {
setNewItem((f) => ({ ...f, bezeichnung: v.bezeichnung, artikel_id: v.id }));
setSelectedKatalogItem(v);
// Load eigenschaften
try {
const eigs = await ausruestungsanfrageApi.getArtikelEigenschaften(v.id);
setKatalogEigenschaften(eigs || []);
} catch { setKatalogEigenschaften([]); }
setEigenschaftValues({});
}
}}
onInputChange={(_, val, reason) => {
if (reason === 'input') {
setNewItem((f) => ({ ...f, bezeichnung: val, artikel_id: undefined }));
setSelectedKatalogItem(null);
setKatalogEigenschaften([]);
setEigenschaftValues({});
}
}}
renderInput={(params) => <TextField {...params} size="small" placeholder="Bezeichnung" />}
renderOption={(props, option) => (
<li {...props} key={option.id}>
<Box>
<Typography variant="body2">{option.bezeichnung}</Typography>
{option.kategorie_name && <Typography variant="caption" color="text.secondary">{option.kategorie_name}</Typography>}
</Box>
</li>
)}
isOptionEqualToValue={(o, v) => o.id === (typeof v === 'object' ? v.id : undefined)}
sx={{ minWidth: 200 }}
/>
</TableCell>
<TableCell>
<TextField size="small" placeholder="Artikelnr." value={newItem.artikelnummer || ''} onChange={(e) => setNewItem((f) => ({ ...f, artikelnummer: e.target.value }))} />
@@ -992,6 +1066,52 @@ export default function BestellungDetail() {
</IconButton>
</TableCell>
</TableRow>
{/* Characteristic fields when catalog item selected */}
{katalogEigenschaften.length > 0 && (
<TableRow>
<TableCell colSpan={8} sx={{ pt: 0, pb: 1, borderBottom: 'none' }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, ml: 1, pl: 1.5, borderLeft: '2px solid', borderColor: 'divider' }}>
{katalogEigenschaften.map((e) =>
e.typ === 'options' && e.optionen?.length ? (
<TextField
key={e.id}
select
size="small"
label={e.name}
required={e.pflicht}
value={eigenschaftValues[e.id] || ''}
onChange={(ev) => setEigenschaftValues((prev) => ({ ...prev, [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={eigenschaftValues[e.id] || ''}
onChange={(ev) => setEigenschaftValues((prev) => ({ ...prev, [e.id]: ev.target.value }))}
sx={{ minWidth: 160 }}
/>
)
)}
{selectedKatalogItem?.bevorzugter_lieferant_name && !bestellung?.lieferant_id && (
<Chip
size="small"
label={`Bevorzugter Lieferant: ${selectedKatalogItem.bevorzugter_lieferant_name}`}
color="info"
variant="outlined"
sx={{ alignSelf: 'center' }}
/>
)}
</Box>
</TableCell>
</TableRow>
)}
</>
)}
{/* ── Totals Rows (Netto / MwSt / Brutto) ── */}

View File

@@ -23,8 +23,10 @@ import {
FormGroup,
LinearProgress,
Divider,
TextField,
MenuItem,
} from '@mui/material';
import { Add as AddIcon, ExpandMore as ExpandMoreIcon, FilterList as FilterListIcon, PictureAsPdf as PdfIcon } from '@mui/icons-material';
import { Add as AddIcon, ExpandMore as ExpandMoreIcon, FilterList as FilterListIcon, PictureAsPdf as PdfIcon, Search as SearchIcon } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import { useNavigate, useSearchParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
@@ -52,7 +54,7 @@ function TabPanel({ children, value, index }: TabPanelProps) {
return <Box sx={{ pt: 3 }}>{children}</Box>;
}
const TAB_COUNT = 2;
const TAB_COUNT = 3;
// ── Status options ──
@@ -85,6 +87,7 @@ 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(() => {
@@ -113,6 +116,26 @@ 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<string, string>();
@@ -251,6 +274,7 @@ export default function Bestellungen() {
<Tabs value={tab} onChange={(_e, v) => { setTab(v); navigate(`/bestellungen?tab=${v}`, { replace: true }); }} variant="scrollable" scrollButtons="auto">
<Tab label="Bestellungen" />
{canManageVendors && <Tab label="Lieferanten" />}
<Tab label="Katalog" />
</Tabs>
</Box>
@@ -458,6 +482,82 @@ export default function Bestellungen() {
</ChatAwareFab>
</TabPanel>
)}
{/* ── Tab 2: Katalog ── */}
<TabPanel value={tab} index={canManageVendors ? 2 : 1}>
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
<TextField
size="small"
placeholder="Suche..."
value={katalogSearch}
onChange={(e) => setKatalogSearch(e.target.value)}
InputProps={{ startAdornment: <SearchIcon fontSize="small" sx={{ mr: 0.5, color: 'text.secondary' }} /> }}
sx={{ flex: 1, maxWidth: 400 }}
/>
<TextField
select
size="small"
label="Kategorie"
value={katalogKategorie}
onChange={(e) => setKatalogKategorie(e.target.value)}
sx={{ minWidth: 180 }}
>
<MenuItem value="">Alle Kategorien</MenuItem>
{katalogKategorien.map((k) => (
<MenuItem key={k} value={k}>{k}</MenuItem>
))}
</TextField>
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Kategorie</TableCell>
<TableCell align="right">Geschätzter Preis</TableCell>
<TableCell>Bevorzugter Lieferant</TableCell>
<TableCell align="right">Eigenschaften</TableCell>
</TableRow>
</TableHead>
<TableBody>
{katalogLoading ? (
<TableRow><TableCell colSpan={5} align="center">Laden...</TableCell></TableRow>
) : katalogItems.length === 0 ? (
<TableRow><TableCell colSpan={5} align="center">Keine Artikel gefunden</TableCell></TableRow>
) : (
katalogItems.map((item) => (
<TableRow
key={item.id}
hover
sx={{ cursor: 'pointer' }}
onClick={() => navigate(`/ausruestungsanfrage/artikel/${item.id}`)}
>
<TableCell>
<Typography variant="body2" fontWeight={500}>{item.bezeichnung}</Typography>
{item.beschreibung && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.beschreibung}
</Typography>
)}
</TableCell>
<TableCell>{item.kategorie_name || item.kategorie || ''}</TableCell>
<TableCell align="right">{item.geschaetzter_preis != null ? formatCurrency(item.geschaetzter_preis) : ''}</TableCell>
<TableCell>{item.bevorzugter_lieferant_name || ''}</TableCell>
<TableCell align="right">{item.eigenschaften_count ?? 0}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
{canManageCatalog && (
<ChatAwareFab onClick={() => navigate('/ausruestungsanfrage/artikel/neu')} aria-label="Neuer Katalogartikel">
<AddIcon />
</ChatAwareFab>
)}
</TabPanel>
</DashboardLayout>
);
}

View File

@@ -48,7 +48,7 @@ export default function LieferantDetail() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
// ── Query ──
const { data: vendor, isLoading, isError } = useQuery({
const { data: vendor, isPending, isLoading, isError } = useQuery({
queryKey: ['lieferant', vendorId],
queryFn: () => bestellungApi.getVendor(vendorId),
enabled: !isNew && !!vendorId,
@@ -128,7 +128,7 @@ export default function LieferantDetail() {
}
// ── Loading / Error ──
if (!isNew && isLoading) {
if (!isNew && isPending) {
return (
<DashboardLayout>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
@@ -144,7 +144,7 @@ export default function LieferantDetail() {
);
}
if (!isNew && (isError || !vendor)) {
if (!isNew && !isPending && (isError || !vendor)) {
return (
<DashboardLayout>
<Box sx={{ p: 4, textAlign: 'center' }}>