shared catalog in Bestellungen, catalog picker in line items, Ersatzbeschaffung flag, vendor detail flash fix
This commit is contained in:
@@ -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
|
// Overview
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -8,6 +8,51 @@ import fs from 'fs';
|
|||||||
const param = (req: Request, key: string): string => req.params[key] as string;
|
const param = (req: Request, key: string): string => req.params[key] as string;
|
||||||
|
|
||||||
class BestellungController {
|
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
|
// Vendors
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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/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
|
// Linking requests to orders
|
||||||
|
|||||||
@@ -52,6 +52,31 @@ router.delete(
|
|||||||
bestellungController.deleteVendor.bind(bestellungController)
|
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)
|
// Orders (Bestellungen)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -422,7 +422,7 @@ async function getRequestById(id: number) {
|
|||||||
|
|
||||||
async function createRequest(
|
async function createRequest(
|
||||||
userId: string,
|
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,
|
notizen?: string,
|
||||||
bezeichnung?: string,
|
bezeichnung?: string,
|
||||||
fuerBenutzerName?: string,
|
fuerBenutzerName?: string,
|
||||||
@@ -476,9 +476,9 @@ async function createRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen)
|
`INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen, ist_ersatz)
|
||||||
VALUES ($1, $2, $3, $4, $5)`,
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
[anfrage.id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null],
|
[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
|
// NOTE: eigenschaft values are NOT saved in the transaction to avoid
|
||||||
@@ -527,7 +527,7 @@ async function updateRequest(
|
|||||||
data: {
|
data: {
|
||||||
bezeichnung?: string;
|
bezeichnung?: string;
|
||||||
notizen?: 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();
|
const client = await pool.connect();
|
||||||
@@ -581,9 +581,9 @@ async function updateRequest(
|
|||||||
}
|
}
|
||||||
const prevGeliefert = geliefertMap.get(`${item.artikel_id ?? 0}:${itemBezeichnung}`) ?? false;
|
const prevGeliefert = geliefertMap.get(`${item.artikel_id ?? 0}:${itemBezeichnung}`) ?? false;
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen, geliefert)
|
`INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen, geliefert, ist_ersatz)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
[id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null, prevGeliefert],
|
[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;
|
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(
|
async function updateRequestStatus(
|
||||||
id: number,
|
id: number,
|
||||||
status: string,
|
status: string,
|
||||||
@@ -768,10 +776,35 @@ async function createOrdersFromRequest(
|
|||||||
const bestellung = bestellungResult.rows[0];
|
const bestellung = bestellungResult.rows[0];
|
||||||
|
|
||||||
for (const pos of orderData.positionen) {
|
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(
|
await client.query(
|
||||||
`INSERT INTO bestellpositionen (bestellung_id, bezeichnung, menge, einheit, notizen)
|
`INSERT INTO bestellpositionen (bestellung_id, bezeichnung, menge, einheit, notizen, artikel_id, spezifikationen)
|
||||||
VALUES ($1, $2, $3, $4, $5)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
|
||||||
[bestellung.id, pos.bezeichnung, pos.menge, pos.einheit || 'Stk', pos.notizen || null]
|
[bestellung.id, pos.bezeichnung, pos.menge, pos.einheit || 'Stk', pos.notizen || null, artikelId, JSON.stringify(spezifikationen)]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -875,6 +908,7 @@ export default {
|
|||||||
getMyRequests,
|
getMyRequests,
|
||||||
getRequestById,
|
getRequestById,
|
||||||
updatePositionGeliefert,
|
updatePositionGeliefert,
|
||||||
|
updatePositionZurueckgegeben,
|
||||||
createRequest,
|
createRequest,
|
||||||
updateRequest,
|
updateRequest,
|
||||||
updateRequestStatus,
|
updateRequestStatus,
|
||||||
|
|||||||
@@ -7,6 +7,38 @@ import logger from '../utils/logger';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import notificationService from './notification.service';
|
import notificationService from './notification.service';
|
||||||
import { permissionService } from './permission.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)
|
// Vendors (Lieferanten)
|
||||||
@@ -168,7 +200,13 @@ async function getOrderById(id: number) {
|
|||||||
if (orderResult.rows.length === 0) return null;
|
if (orderResult.rows.length === 0) return null;
|
||||||
|
|
||||||
const [positionen, dateien, erinnerungen, historie] = await Promise.all([
|
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_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 * 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]),
|
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)
|
// 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 {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO bestellpositionen (bestellung_id, bezeichnung, artikelnummer, menge, einheit, einzelpreis, notizen, spezifikationen)
|
`INSERT INTO bestellpositionen (bestellung_id, bezeichnung, artikelnummer, menge, einheit, einzelpreis, notizen, spezifikationen, artikel_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)
|
||||||
RETURNING *`,
|
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);
|
await logAction(bestellungId, 'Position hinzugefügt', `"${data.bezeichnung}" x${data.menge}`, userId);
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
@@ -693,6 +731,10 @@ async function getHistory(bestellungId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
// Catalog
|
||||||
|
getKatalogItems,
|
||||||
|
getKatalogItem,
|
||||||
|
getKatalogKategorien,
|
||||||
// Vendors
|
// Vendors
|
||||||
getVendors,
|
getVendors,
|
||||||
getVendorById,
|
getVendorById,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
Table, TableBody, TableCell, TableHead, TableRow,
|
Table, TableBody, TableCell, TableHead, TableRow,
|
||||||
Dialog, DialogTitle, DialogContent, DialogActions, TextField,
|
Dialog, DialogTitle, DialogContent, DialogActions, TextField,
|
||||||
MenuItem, Select, FormControl, InputLabel, Autocomplete,
|
MenuItem, Select, FormControl, InputLabel, Autocomplete,
|
||||||
Checkbox, LinearProgress,
|
Checkbox, LinearProgress, Switch, FormControlLabel, Alert,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
ArrowBack, Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon,
|
ArrowBack, Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon,
|
||||||
@@ -123,6 +123,15 @@ export default function AusruestungsanfrageDetail() {
|
|||||||
onError: () => showError('Fehler beim Aktualisieren'),
|
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 ──
|
// ── Edit helpers ──
|
||||||
const startEditing = () => {
|
const startEditing = () => {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
@@ -134,6 +143,7 @@ export default function AusruestungsanfrageDetail() {
|
|||||||
menge: p.menge,
|
menge: p.menge,
|
||||||
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 })),
|
||||||
|
ist_ersatz: p.ist_ersatz || false,
|
||||||
})));
|
})));
|
||||||
const initVals: Record<number, Record<number, string>> = {};
|
const initVals: Record<number, Record<number, string>> = {};
|
||||||
detail.positionen.forEach((p, idx) => {
|
detail.positionen.forEach((p, idx) => {
|
||||||
@@ -290,6 +300,23 @@ export default function AusruestungsanfrageDetail() {
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
))}
|
))}
|
||||||
<Button size="small" startIcon={<AddIcon />} onClick={addEditItem}>
|
<Button size="small" startIcon={<AddIcon />} onClick={addEditItem}>
|
||||||
@@ -376,19 +403,33 @@ export default function AusruestungsanfrageDetail() {
|
|||||||
)}
|
)}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Typography variant="body2" fontWeight={500} sx={p.geliefert ? { textDecoration: 'line-through' } : undefined}>{p.bezeichnung}</Typography>
|
<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 }}>
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 0.5 }}>
|
||||||
{p.eigenschaften.map(e => (
|
{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" />
|
<Chip key={e.eigenschaft_id} label={`${e.eigenschaft_name}: ${e.wert}`} size="small" variant="outlined" />
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
<Typography variant="body2" fontWeight={600}>{p.menge}x</Typography>
|
<Typography variant="body2" fontWeight={600}>{p.menge}x</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -194,6 +194,10 @@ export default function AusruestungsanfrageZuBestellung() {
|
|||||||
menge: p.menge,
|
menge: p.menge,
|
||||||
einheit: p.einheit,
|
einheit: p.einheit,
|
||||||
notizen: p.notizen,
|
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 });
|
createOrdersMut.mutate({ orders });
|
||||||
|
|||||||
@@ -51,10 +51,12 @@ import GermanDateField from '../components/shared/GermanDateField';
|
|||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { bestellungApi } from '../services/bestellung';
|
import { bestellungApi } from '../services/bestellung';
|
||||||
|
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
||||||
import { configApi } from '../services/config';
|
import { configApi } from '../services/config';
|
||||||
import { addPdfHeader, addPdfFooter } from '../utils/pdfExport';
|
import { addPdfHeader, addPdfFooter } from '../utils/pdfExport';
|
||||||
import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types';
|
import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types';
|
||||||
import type { BestellungStatus, BestellpositionFormData, ErinnerungFormData } from '../types/bestellung.types';
|
import type { BestellungStatus, BestellpositionFormData, ErinnerungFormData } from '../types/bestellung.types';
|
||||||
|
import type { AusruestungArtikel, AusruestungEigenschaft } from '../types/ausruestungsanfrage.types';
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
||||||
@@ -159,6 +161,11 @@ export default function BestellungDetail() {
|
|||||||
const [reminderFormOpen, setReminderFormOpen] = useState(false);
|
const [reminderFormOpen, setReminderFormOpen] = useState(false);
|
||||||
const [deleteReminderTarget, setDeleteReminderTarget] = useState<number | null>(null);
|
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 ──
|
// ── Query ──
|
||||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||||
queryKey: ['bestellung', orderId],
|
queryKey: ['bestellung', orderId],
|
||||||
@@ -184,6 +191,13 @@ export default function BestellungDetail() {
|
|||||||
enabled: editMode,
|
enabled: editMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: katalogItems = [] } = useQuery({
|
||||||
|
queryKey: ['katalogItems'],
|
||||||
|
queryFn: () => bestellungApi.getKatalogItems(),
|
||||||
|
enabled: editMode,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
const canCreate = hasPermission('bestellungen:create');
|
const canCreate = hasPermission('bestellungen:create');
|
||||||
const canDelete = hasPermission('bestellungen:delete');
|
const canDelete = hasPermission('bestellungen:delete');
|
||||||
const canManageReminders = hasPermission('bestellungen:manage_reminders');
|
const canManageReminders = hasPermission('bestellungen:manage_reminders');
|
||||||
@@ -214,6 +228,9 @@ export default function BestellungDetail() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
|
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
|
||||||
setNewItem({ ...emptyItem });
|
setNewItem({ ...emptyItem });
|
||||||
|
setSelectedKatalogItem(null);
|
||||||
|
setKatalogEigenschaften([]);
|
||||||
|
setEigenschaftValues({});
|
||||||
showSuccess('Position hinzugefügt');
|
showSuccess('Position hinzugefügt');
|
||||||
},
|
},
|
||||||
onError: () => showError('Fehler beim Hinzufügen der Position'),
|
onError: () => showError('Fehler beim Hinzufügen der Position'),
|
||||||
@@ -348,7 +365,21 @@ export default function BestellungDetail() {
|
|||||||
|
|
||||||
function handleAddItem() {
|
function handleAddItem() {
|
||||||
if (!newItem.bezeichnung.trim()) return;
|
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>) {
|
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
@@ -968,9 +999,52 @@ export default function BestellungDetail() {
|
|||||||
|
|
||||||
{/* ── Add Item Row ── */}
|
{/* ── Add Item Row ── */}
|
||||||
{editMode && canCreate && (
|
{editMode && canCreate && (
|
||||||
|
<>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>
|
<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>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<TextField size="small" placeholder="Artikelnr." value={newItem.artikelnummer || ''} onChange={(e) => setNewItem((f) => ({ ...f, artikelnummer: e.target.value }))} />
|
<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>
|
</IconButton>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</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) ── */}
|
{/* ── Totals Rows (Netto / MwSt / Brutto) ── */}
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ import {
|
|||||||
FormGroup,
|
FormGroup,
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
Divider,
|
Divider,
|
||||||
|
TextField,
|
||||||
|
MenuItem,
|
||||||
} from '@mui/material';
|
} 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 { useQuery } from '@tanstack/react-query';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
@@ -52,7 +54,7 @@ function TabPanel({ children, value, index }: TabPanelProps) {
|
|||||||
return <Box sx={{ pt: 3 }}>{children}</Box>;
|
return <Box sx={{ pt: 3 }}>{children}</Box>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAB_COUNT = 2;
|
const TAB_COUNT = 3;
|
||||||
|
|
||||||
// ── Status options ──
|
// ── Status options ──
|
||||||
|
|
||||||
@@ -85,6 +87,7 @@ export default function Bestellungen() {
|
|||||||
const { hasPermission } = usePermissionContext();
|
const { hasPermission } = usePermissionContext();
|
||||||
const canManageVendors = hasPermission('bestellungen:manage_vendors');
|
const canManageVendors = hasPermission('bestellungen:manage_vendors');
|
||||||
const canExport = hasPermission('bestellungen:export');
|
const canExport = hasPermission('bestellungen:export');
|
||||||
|
const canManageCatalog = hasPermission('ausruestungsanfrage:manage_catalog');
|
||||||
|
|
||||||
// Tab from URL
|
// Tab from URL
|
||||||
const [tab, setTab] = useState(() => {
|
const [tab, setTab] = useState(() => {
|
||||||
@@ -113,6 +116,26 @@ export default function Bestellungen() {
|
|||||||
queryFn: bestellungApi.getVendors,
|
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 ──
|
// ── Derive unique filter values from data ──
|
||||||
const uniqueVendors = useMemo(() => {
|
const uniqueVendors = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
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">
|
<Tabs value={tab} onChange={(_e, v) => { setTab(v); navigate(`/bestellungen?tab=${v}`, { replace: true }); }} variant="scrollable" scrollButtons="auto">
|
||||||
<Tab label="Bestellungen" />
|
<Tab label="Bestellungen" />
|
||||||
{canManageVendors && <Tab label="Lieferanten" />}
|
{canManageVendors && <Tab label="Lieferanten" />}
|
||||||
|
<Tab label="Katalog" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -458,6 +482,82 @@ export default function Bestellungen() {
|
|||||||
</ChatAwareFab>
|
</ChatAwareFab>
|
||||||
</TabPanel>
|
</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>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function LieferantDetail() {
|
|||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
|
||||||
// ── Query ──
|
// ── Query ──
|
||||||
const { data: vendor, isLoading, isError } = useQuery({
|
const { data: vendor, isPending, isLoading, isError } = useQuery({
|
||||||
queryKey: ['lieferant', vendorId],
|
queryKey: ['lieferant', vendorId],
|
||||||
queryFn: () => bestellungApi.getVendor(vendorId),
|
queryFn: () => bestellungApi.getVendor(vendorId),
|
||||||
enabled: !isNew && !!vendorId,
|
enabled: !isNew && !!vendorId,
|
||||||
@@ -128,7 +128,7 @@ export default function LieferantDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Loading / Error ──
|
// ── Loading / Error ──
|
||||||
if (!isNew && isLoading) {
|
if (!isNew && isPending) {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
<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 (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ export const ausruestungsanfrageApi = {
|
|||||||
await api.patch(`/api/ausruestungsanfragen/positionen/${positionId}/geliefert`, { geliefert });
|
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 ──
|
// ── Linking ──
|
||||||
linkToOrder: async (anfrageId: number, bestellungId: number): Promise<void> => {
|
linkToOrder: async (anfrageId: number, bestellungId: number): Promise<void> => {
|
||||||
await api.post(`/api/ausruestungsanfragen/requests/${anfrageId}/link`, { bestellung_id: bestellungId });
|
await api.post(`/api/ausruestungsanfragen/requests/${anfrageId}/link`, { bestellung_id: bestellungId });
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
ErinnerungFormData,
|
ErinnerungFormData,
|
||||||
BestellungHistorie,
|
BestellungHistorie,
|
||||||
} from '../types/bestellung.types';
|
} from '../types/bestellung.types';
|
||||||
|
import type { AusruestungArtikel, AusruestungEigenschaft } from '../types/ausruestungsanfrage.types';
|
||||||
|
|
||||||
export const bestellungApi = {
|
export const bestellungApi = {
|
||||||
// ── Vendors ──
|
// ── Vendors ──
|
||||||
@@ -120,4 +121,21 @@ export const bestellungApi = {
|
|||||||
const r = await api.get('/api/permissions/users-with', { params: { permission: 'bestellungen:create' } });
|
const r = await api.get('/api/permissions/users-with', { params: { permission: 'bestellungen:create' } });
|
||||||
return r.data.data;
|
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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ export interface AusruestungAnfragePosition {
|
|||||||
geliefert: boolean;
|
geliefert: boolean;
|
||||||
erstellt_am: string;
|
erstellt_am: string;
|
||||||
eigenschaften?: AusruestungPositionEigenschaft[];
|
eigenschaften?: AusruestungPositionEigenschaft[];
|
||||||
|
ist_ersatz: boolean;
|
||||||
|
altes_geraet_zurueckgegeben: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AusruestungAnfrageFormItem {
|
export interface AusruestungAnfrageFormItem {
|
||||||
@@ -117,6 +119,7 @@ export interface AusruestungAnfrageFormItem {
|
|||||||
menge: number;
|
menge: number;
|
||||||
notizen?: string;
|
notizen?: string;
|
||||||
eigenschaften?: { eigenschaft_id: number; wert: string }[];
|
eigenschaften?: { eigenschaft_id: number; wert: string }[];
|
||||||
|
ist_ersatz?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── API Response Types ──
|
// ── API Response Types ──
|
||||||
@@ -158,6 +161,8 @@ export interface CreateOrderPositionPayload {
|
|||||||
menge: number;
|
menge: number;
|
||||||
einheit?: string;
|
einheit?: string;
|
||||||
notizen?: string;
|
notizen?: string;
|
||||||
|
artikel_id?: number;
|
||||||
|
spezifikationen?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateOrderPayload {
|
export interface CreateOrderPayload {
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ export interface Bestellposition {
|
|||||||
erstellt_am: string;
|
erstellt_am: string;
|
||||||
aktualisiert_am: string;
|
aktualisiert_am: string;
|
||||||
spezifikationen?: string[];
|
spezifikationen?: string[];
|
||||||
|
artikel_id?: number;
|
||||||
|
artikel_bezeichnung?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BestellpositionFormData {
|
export interface BestellpositionFormData {
|
||||||
@@ -118,6 +120,7 @@ export interface BestellpositionFormData {
|
|||||||
einzelpreis?: number;
|
einzelpreis?: number;
|
||||||
notizen?: string;
|
notizen?: string;
|
||||||
spezifikationen?: string[];
|
spezifikationen?: string[];
|
||||||
|
artikel_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── File Attachments ──
|
// ── File Attachments ──
|
||||||
|
|||||||
Reference in New Issue
Block a user