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

@@ -460,6 +460,29 @@ class AusruestungsanfrageController {
}
}
async updatePositionZurueckgegeben(req: Request, res: Response): Promise<void> {
try {
const positionId = Number(req.params.positionId);
const { altes_geraet_zurueckgegeben } = req.body as { altes_geraet_zurueckgegeben?: boolean };
if (typeof altes_geraet_zurueckgegeben !== 'boolean') {
res.status(400).json({ success: false, message: 'altes_geraet_zurueckgegeben (boolean) ist erforderlich' });
return;
}
const position = await ausruestungsanfrageService.updatePositionZurueckgegeben(positionId, altes_geraet_zurueckgegeben);
if (!position) {
res.status(404).json({ success: false, message: 'Position nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: position });
} catch (error) {
logger.error('AusruestungsanfrageController.updatePositionZurueckgegeben error', { error });
res.status(500).json({ success: false, message: 'Rückgabestatus konnte nicht aktualisiert werden' });
}
}
// -------------------------------------------------------------------------
// Overview
// -------------------------------------------------------------------------

View File

@@ -8,6 +8,51 @@ import fs from 'fs';
const param = (req: Request, key: string): string => req.params[key] as string;
class BestellungController {
// ---------------------------------------------------------------------------
// Catalog (shared ausruestung_artikel)
// ---------------------------------------------------------------------------
async listKatalogItems(req: Request, res: Response): Promise<void> {
try {
const search = req.query.search as string | undefined;
const kategorie = req.query.kategorie as string | undefined;
const items = await bestellungService.getKatalogItems({ search, kategorie });
res.status(200).json({ success: true, data: items });
} catch (error) {
logger.error('BestellungController.listKatalogItems error', { error });
res.status(500).json({ success: false, message: 'Katalogartikel konnten nicht geladen werden' });
}
}
async getKatalogItem(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'itemId'), 10);
if (isNaN(id)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
try {
const item = await bestellungService.getKatalogItem(id);
if (!item) {
res.status(404).json({ success: false, message: 'Katalogartikel nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: item });
} catch (error) {
logger.error('BestellungController.getKatalogItem error', { error });
res.status(500).json({ success: false, message: 'Katalogartikel konnte nicht geladen werden' });
}
}
async listKatalogKategorien(_req: Request, res: Response): Promise<void> {
try {
const kategorien = await bestellungService.getKatalogKategorien();
res.status(200).json({ success: true, data: kategorien });
} catch (error) {
logger.error('BestellungController.listKatalogKategorien error', { error });
res.status(500).json({ success: false, message: 'Kategorien konnten nicht geladen werden' });
}
}
// ---------------------------------------------------------------------------
// Vendors
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,9 @@
-- Migration 065: Shared catalog + request type fields
-- Link bestellpositionen to ausruestung_artikel (shared catalog)
ALTER TABLE bestellpositionen
ADD COLUMN IF NOT EXISTS artikel_id INT REFERENCES ausruestung_artikel(id) ON DELETE SET NULL;
-- Add replacement/return fields to ausruestung_anfrage_positionen
ALTER TABLE ausruestung_anfrage_positionen
ADD COLUMN IF NOT EXISTS ist_ersatz BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS altes_geraet_zurueckgegeben BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -62,6 +62,7 @@ router.delete('/requests/:id', authenticate, requirePermission('ausruestungsanfr
// ---------------------------------------------------------------------------
router.patch('/positionen/:positionId/geliefert', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.updatePositionGeliefert.bind(ausruestungsanfrageController));
router.patch('/positionen/:positionId/zurueckgegeben', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.updatePositionZurueckgegeben.bind(ausruestungsanfrageController));
// ---------------------------------------------------------------------------
// Linking requests to orders

View File

@@ -52,6 +52,31 @@ router.delete(
bestellungController.deleteVendor.bind(bestellungController)
);
// ---------------------------------------------------------------------------
// Catalog (shared ausruestung_artikel, accessed via bestellungen context)
// ---------------------------------------------------------------------------
router.get(
'/katalog/items',
authenticate,
requirePermission('bestellungen:view'),
bestellungController.listKatalogItems.bind(bestellungController)
);
router.get(
'/katalog/items/:itemId',
authenticate,
requirePermission('bestellungen:view'),
bestellungController.getKatalogItem.bind(bestellungController)
);
router.get(
'/katalog/kategorien',
authenticate,
requirePermission('bestellungen:view'),
bestellungController.listKatalogKategorien.bind(bestellungController)
);
// ---------------------------------------------------------------------------
// Orders (Bestellungen)
// ---------------------------------------------------------------------------

View File

@@ -422,7 +422,7 @@ async function getRequestById(id: number) {
async function createRequest(
userId: string,
items: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[],
items: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; ist_ersatz?: boolean; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[],
notizen?: string,
bezeichnung?: string,
fuerBenutzerName?: string,
@@ -476,9 +476,9 @@ async function createRequest(
}
await client.query(
`INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen)
VALUES ($1, $2, $3, $4, $5)`,
[anfrage.id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null],
`INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen, ist_ersatz)
VALUES ($1, $2, $3, $4, $5, $6)`,
[anfrage.id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null, item.ist_ersatz ?? false],
);
// NOTE: eigenschaft values are NOT saved in the transaction to avoid
@@ -527,7 +527,7 @@ async function updateRequest(
data: {
bezeichnung?: string;
notizen?: string;
items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[];
items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; ist_ersatz?: boolean; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[];
},
) {
const client = await pool.connect();
@@ -581,9 +581,9 @@ async function updateRequest(
}
const prevGeliefert = geliefertMap.get(`${item.artikel_id ?? 0}:${itemBezeichnung}`) ?? false;
await client.query(
`INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen, geliefert)
VALUES ($1, $2, $3, $4, $5, $6)`,
[id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null, prevGeliefert],
`INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen, geliefert, ist_ersatz)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null, prevGeliefert, item.ist_ersatz ?? false],
);
}
}
@@ -652,6 +652,14 @@ async function updatePositionGeliefert(positionId: number, geliefert: boolean) {
return position;
}
async function updatePositionZurueckgegeben(positionId: number, zurueckgegeben: boolean) {
const result = await pool.query(
`UPDATE ausruestung_anfrage_positionen SET altes_geraet_zurueckgegeben = $1 WHERE id = $2 RETURNING *`,
[zurueckgegeben, positionId],
);
return result.rows[0] || null;
}
async function updateRequestStatus(
id: number,
status: string,
@@ -768,10 +776,35 @@ async function createOrdersFromRequest(
const bestellung = bestellungResult.rows[0];
for (const pos of orderData.positionen) {
// Look up the anfrage position to get artikel_id and eigenschaften
let artikelId: number | null = null;
let spezifikationen: string[] = [];
if (pos.position_id) {
const posResult = await client.query(
`SELECT p.artikel_id FROM ausruestung_anfrage_positionen p WHERE p.id = $1`,
[pos.position_id]
);
if (posResult.rows.length > 0) {
artikelId = posResult.rows[0].artikel_id || null;
}
// Load eigenschaften and map to spezifikationen strings
try {
const eigResult = await client.query(
`SELECT ae.name AS eigenschaft_name, pe.wert
FROM ausruestung_position_eigenschaften pe
JOIN ausruestung_artikel_eigenschaften ae ON ae.id = pe.eigenschaft_id
WHERE pe.position_id = $1
ORDER BY ae.sort_order, ae.id`,
[pos.position_id]
);
spezifikationen = eigResult.rows.map((e: { eigenschaft_name: string; wert: string }) => `${e.eigenschaft_name}: ${e.wert}`);
} catch { /* table may not exist */ }
}
await client.query(
`INSERT INTO bestellpositionen (bestellung_id, bezeichnung, menge, einheit, notizen)
VALUES ($1, $2, $3, $4, $5)`,
[bestellung.id, pos.bezeichnung, pos.menge, pos.einheit || 'Stk', pos.notizen || null]
`INSERT INTO bestellpositionen (bestellung_id, bezeichnung, menge, einheit, notizen, artikel_id, spezifikationen)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
[bestellung.id, pos.bezeichnung, pos.menge, pos.einheit || 'Stk', pos.notizen || null, artikelId, JSON.stringify(spezifikationen)]
);
}
@@ -875,6 +908,7 @@ export default {
getMyRequests,
getRequestById,
updatePositionGeliefert,
updatePositionZurueckgegeben,
createRequest,
updateRequest,
updateRequestStatus,

View File

@@ -7,6 +7,38 @@ import logger from '../utils/logger';
import fs from 'fs';
import notificationService from './notification.service';
import { permissionService } from './permission.service';
import ausruestungsanfrageService from './ausruestungsanfrage.service';
// ---------------------------------------------------------------------------
// Catalog (shared ausruestung_artikel via ausruestungsanfrageService)
// ---------------------------------------------------------------------------
async function getKatalogItems(filters?: { search?: string; kategorie?: string }) {
try {
return await ausruestungsanfrageService.getItems({ search: filters?.search, kategorie: filters?.kategorie, aktiv: true });
} catch (error) {
logger.error('BestellungService.getKatalogItems failed', { error });
throw new Error('Katalogartikel konnten nicht geladen werden');
}
}
async function getKatalogItem(id: number) {
try {
return await ausruestungsanfrageService.getItemById(id);
} catch (error) {
logger.error('BestellungService.getKatalogItem failed', { error, id });
throw new Error('Katalogartikel konnte nicht geladen werden');
}
}
async function getKatalogKategorien() {
try {
return await ausruestungsanfrageService.getKategorien();
} catch (error) {
logger.error('BestellungService.getKatalogKategorien failed', { error });
throw new Error('Katalogkategorien konnten nicht geladen werden');
}
}
// ---------------------------------------------------------------------------
// Vendors (Lieferanten)
@@ -168,7 +200,13 @@ async function getOrderById(id: number) {
if (orderResult.rows.length === 0) return null;
const [positionen, dateien, erinnerungen, historie] = await Promise.all([
pool.query(`SELECT * FROM bestellpositionen WHERE bestellung_id = $1 ORDER BY id`, [id]),
pool.query(
`SELECT bp.*, aa.bezeichnung AS artikel_bezeichnung
FROM bestellpositionen bp
LEFT JOIN ausruestung_artikel aa ON aa.id = bp.artikel_id
WHERE bp.bestellung_id = $1 ORDER BY bp.id`,
[id]
),
pool.query(`SELECT * FROM bestellung_dateien WHERE bestellung_id = $1 ORDER BY hochgeladen_am DESC`, [id]),
pool.query(`SELECT * FROM bestellung_erinnerungen WHERE bestellung_id = $1 ORDER BY faellig_am`, [id]),
pool.query(`SELECT h.*, COALESCE(u.name, u.preferred_username, u.email) AS benutzer_name FROM bestellung_historie h LEFT JOIN users u ON u.id = h.erstellt_von WHERE h.bestellung_id = $1 ORDER BY h.erstellt_am DESC`, [id]),
@@ -425,13 +463,13 @@ async function updateOrderStatus(id: number, status: string, userId: string, for
// Line Items (Bestellpositionen)
// ---------------------------------------------------------------------------
async function addLineItem(bestellungId: number, data: { bezeichnung: string; artikelnummer?: string; menge: number; einheit?: string; einzelpreis?: number; notizen?: string; spezifikationen?: string[] }, userId: string) {
async function addLineItem(bestellungId: number, data: { bezeichnung: string; artikelnummer?: string; menge: number; einheit?: string; einzelpreis?: number; notizen?: string; spezifikationen?: string[]; artikel_id?: number }, userId: string) {
try {
const result = await pool.query(
`INSERT INTO bestellpositionen (bestellung_id, bezeichnung, artikelnummer, menge, einheit, einzelpreis, notizen, spezifikationen)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
`INSERT INTO bestellpositionen (bestellung_id, bezeichnung, artikelnummer, menge, einheit, einzelpreis, notizen, spezifikationen, artikel_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)
RETURNING *`,
[bestellungId, data.bezeichnung, data.artikelnummer || null, data.menge, data.einheit || 'Stk', data.einzelpreis || 0, data.notizen || null, JSON.stringify(data.spezifikationen || [])]
[bestellungId, data.bezeichnung, data.artikelnummer || null, data.menge, data.einheit || 'Stk', data.einzelpreis || 0, data.notizen || null, JSON.stringify(data.spezifikationen || []), data.artikel_id || null]
);
await logAction(bestellungId, 'Position hinzugefügt', `"${data.bezeichnung}" x${data.menge}`, userId);
return result.rows[0];
@@ -693,6 +731,10 @@ async function getHistory(bestellungId: number) {
}
export default {
// Catalog
getKatalogItems,
getKatalogItem,
getKatalogKategorien,
// Vendors
getVendors,
getVendorById,

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' }}>

View File

@@ -122,6 +122,10 @@ export const ausruestungsanfrageApi = {
await api.patch(`/api/ausruestungsanfragen/positionen/${positionId}/geliefert`, { geliefert });
},
updatePositionZurueckgegeben: async (positionId: number, altes_geraet_zurueckgegeben: boolean): Promise<void> => {
await api.patch(`/api/ausruestungsanfragen/positionen/${positionId}/zurueckgegeben`, { altes_geraet_zurueckgegeben });
},
// ── Linking ──
linkToOrder: async (anfrageId: number, bestellungId: number): Promise<void> => {
await api.post(`/api/ausruestungsanfragen/requests/${anfrageId}/link`, { bestellung_id: bestellungId });

View File

@@ -12,6 +12,7 @@ import type {
ErinnerungFormData,
BestellungHistorie,
} from '../types/bestellung.types';
import type { AusruestungArtikel, AusruestungEigenschaft } from '../types/ausruestungsanfrage.types';
export const bestellungApi = {
// ── Vendors ──
@@ -120,4 +121,21 @@ export const bestellungApi = {
const r = await api.get('/api/permissions/users-with', { params: { permission: 'bestellungen:create' } });
return r.data.data;
},
// ── Catalog ──
getKatalogItems: async (filters?: { search?: string; kategorie?: string }): Promise<AusruestungArtikel[]> => {
const params = new URLSearchParams();
if (filters?.search) params.set('search', filters.search);
if (filters?.kategorie) params.set('kategorie', filters.kategorie);
const r = await api.get(`/api/bestellungen/katalog/items?${params.toString()}`);
return r.data.data;
},
getKatalogItem: async (id: number): Promise<AusruestungArtikel> => {
const r = await api.get(`/api/bestellungen/katalog/items/${id}`);
return r.data.data;
},
getKatalogKategorien: async (): Promise<string[]> => {
const r = await api.get('/api/bestellungen/katalog/kategorien');
return r.data.data;
},
};

View File

@@ -109,6 +109,8 @@ export interface AusruestungAnfragePosition {
geliefert: boolean;
erstellt_am: string;
eigenschaften?: AusruestungPositionEigenschaft[];
ist_ersatz: boolean;
altes_geraet_zurueckgegeben: boolean;
}
export interface AusruestungAnfrageFormItem {
@@ -117,6 +119,7 @@ export interface AusruestungAnfrageFormItem {
menge: number;
notizen?: string;
eigenschaften?: { eigenschaft_id: number; wert: string }[];
ist_ersatz?: boolean;
}
// ── API Response Types ──
@@ -158,6 +161,8 @@ export interface CreateOrderPositionPayload {
menge: number;
einheit?: string;
notizen?: string;
artikel_id?: number;
spezifikationen?: string[];
}
export interface CreateOrderPayload {

View File

@@ -108,6 +108,8 @@ export interface Bestellposition {
erstellt_am: string;
aktualisiert_am: string;
spezifikationen?: string[];
artikel_id?: number;
artikel_bezeichnung?: string;
}
export interface BestellpositionFormData {
@@ -118,6 +120,7 @@ export interface BestellpositionFormData {
einzelpreis?: number;
notizen?: string;
spezifikationen?: string[];
artikel_id?: number;
}
// ── File Attachments ──