diff --git a/backend/routes/lieferungen.js b/backend/routes/lieferungen.js index 95f07ca..df5696a 100644 --- a/backend/routes/lieferungen.js +++ b/backend/routes/lieferungen.js @@ -74,4 +74,65 @@ router.delete('/:id', async (req, res) => { } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } }); + +// POST /bulk - mehrere Positionen mit einem gemeinsamen Foto +router.post('/bulk', upload.single('foto'), async (req, res) => { + let positionen; + try { + positionen = JSON.parse(req.body.positionen || '[]'); + } catch { + return res.status(400).json({ error: 'positionen muss gueltiges JSON-Array sein' }); + } + if (!Array.isArray(positionen) || positionen.length === 0) + return res.status(400).json({ error: 'Mindestens eine Position erforderlich' }); + for (const p of positionen) { + if (!p.artikel_id || !Number.isInteger(parseInt(p.anzahl)) || parseInt(p.anzahl) < 1) + return res.status(400).json({ error: 'Jede Position braucht artikel_id und anzahl >= 1' }); + } + const kundeId = req.body.kunde_id; + if (!kundeId) return res.status(400).json({ error: 'kunde_id fehlt' }); + + const foto_url = req.file ? `/uploads/${req.file.filename}` : null; + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [[k]] = await conn.query('SELECT id FROM kunden WHERE id=? AND tenant_id=?', + [kundeId, req.user.tenant_id]); + if (!k) { + await conn.rollback(); + return res.status(400).json({ error: 'Kunde nicht im Tenant' }); + } + const artikelIds = positionen.map(p => p.artikel_id); + const [artRows] = await conn.query( + `SELECT id FROM artikel WHERE tenant_id=? AND id IN (?)`, + [req.user.tenant_id, artikelIds] + ); + if (artRows.length !== new Set(artikelIds).size) { + await conn.rollback(); + return res.status(400).json({ error: 'Mindestens ein Artikel nicht im Tenant' }); + } + const insertedIds = []; + for (const p of positionen) { + const [r] = await conn.query( + `INSERT INTO lieferungen (tenant_id, kunde_id, artikel_id, anzahl, foto_url, mitarbeiter_id) + VALUES (?, ?, ?, ?, ?, ?)`, + [req.user.tenant_id, kundeId, p.artikel_id, parseInt(p.anzahl), foto_url, req.user.id] + ); + insertedIds.push(r.insertId); + } + await conn.commit(); + const [rows] = await pool.query( + `${baseSelect} WHERE l.id IN (?) ORDER BY l.id`, + [insertedIds] + ); + res.status(201).json({ count: rows.length, items: rows, foto_url }); + } catch (e) { + await conn.rollback(); + console.error('POST lieferungen bulk:', e); + res.status(500).json({ error: 'Serverfehler' }); + } finally { + conn.release(); + } +}); + export default router; diff --git a/backend/routes/ruecknahmen.js b/backend/routes/ruecknahmen.js index d5775d0..d80c743 100644 --- a/backend/routes/ruecknahmen.js +++ b/backend/routes/ruecknahmen.js @@ -73,4 +73,65 @@ router.delete('/:id', async (req, res) => { } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } }); + +// POST /bulk - mehrere Positionen mit einem gemeinsamen Foto +router.post('/bulk', upload.single('foto'), async (req, res) => { + let positionen; + try { + positionen = JSON.parse(req.body.positionen || '[]'); + } catch { + return res.status(400).json({ error: 'positionen muss gueltiges JSON-Array sein' }); + } + if (!Array.isArray(positionen) || positionen.length === 0) + return res.status(400).json({ error: 'Mindestens eine Position erforderlich' }); + for (const p of positionen) { + if (!p.artikel_id || !Number.isInteger(parseInt(p.anzahl)) || parseInt(p.anzahl) < 1) + return res.status(400).json({ error: 'Jede Position braucht artikel_id und anzahl >= 1' }); + } + const kundeId = req.body.kunde_id; + if (!kundeId) return res.status(400).json({ error: 'kunde_id fehlt' }); + + const foto_url = req.file ? `/uploads/${req.file.filename}` : null; + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [[k]] = await conn.query('SELECT id FROM kunden WHERE id=? AND tenant_id=?', + [kundeId, req.user.tenant_id]); + if (!k) { + await conn.rollback(); + return res.status(400).json({ error: 'Kunde nicht im Tenant' }); + } + const artikelIds = positionen.map(p => p.artikel_id); + const [artRows] = await conn.query( + `SELECT id FROM artikel WHERE tenant_id=? AND id IN (?)`, + [req.user.tenant_id, artikelIds] + ); + if (artRows.length !== new Set(artikelIds).size) { + await conn.rollback(); + return res.status(400).json({ error: 'Mindestens ein Artikel nicht im Tenant' }); + } + const insertedIds = []; + for (const p of positionen) { + const [r] = await conn.query( + `INSERT INTO ruecknahmen (tenant_id, kunde_id, artikel_id, anzahl, foto_url, mitarbeiter_id) + VALUES (?, ?, ?, ?, ?, ?)`, + [req.user.tenant_id, kundeId, p.artikel_id, parseInt(p.anzahl), foto_url, req.user.id] + ); + insertedIds.push(r.insertId); + } + await conn.commit(); + const [rows] = await pool.query( + `${baseSelect} WHERE r.id IN (?) ORDER BY r.id`, + [insertedIds] + ); + res.status(201).json({ count: rows.length, items: rows, foto_url }); + } catch (e) { + await conn.rollback(); + console.error('POST ruecknahmen bulk:', e); + res.status(500).json({ error: 'Serverfehler' }); + } finally { + conn.release(); + } +}); + export default router; diff --git a/src/api/client.js b/src/api/client.js index 6ebc0f2..7dc0e07 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -149,6 +149,26 @@ class ApiClient { createTenant(t) { return this.request('/tenants', { method: 'POST', body: JSON.stringify(t) }); } updateTenant(id, t) { return this.request(`/tenants/${id}`, { method: 'PATCH', body: JSON.stringify(t) }); } deleteTenant(id) { return this.request(`/tenants/${id}`, { method: 'DELETE' }); } + async createLieferungBulk(kundeId, positionen, foto) { + return this._bulk('/lieferungen/bulk', kundeId, positionen, foto); + } + async createRuecknahmeBulk(kundeId, positionen, foto) { + return this._bulk('/ruecknahmen/bulk', kundeId, positionen, foto); + } + async _bulk(endpoint, kundeId, positionen, foto) { + const fd = new FormData(); + fd.append('kunde_id', kundeId); + fd.append('positionen', JSON.stringify(positionen)); + if (foto) fd.append('foto', foto); + const res = await fetch(`${API_BASE_URL}${endpoint}`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${this.getToken()}` }, + body: fd, + }); + if (!res.ok) { const e = await res.json().catch(()=>({})); throw new Error(e.error || `HTTP ${res.status}`); } + return res.json(); + } + } export const api = new ApiClient(); diff --git a/src/components/Eingabe/VorgangForm.jsx b/src/components/Eingabe/VorgangForm.jsx index 88dcc8e..2a42355 100644 --- a/src/components/Eingabe/VorgangForm.jsx +++ b/src/components/Eingabe/VorgangForm.jsx @@ -1,18 +1,20 @@ import { useEffect, useState } from 'react'; -import { FaCamera, FaInfoCircle } from 'react-icons/fa'; +import { FaCamera, FaInfoCircle, FaPlus, FaTimes } from 'react-icons/fa'; import { api } from '../../api/client'; const API_BASE = import.meta.env.VITE_API_URL || '/api'; +const newRow = () => ({ artikel_id: '', anzahl: 1, key: Math.random().toString(36).slice(2) }); + export default function VorgangForm({ onSubmit }) { const [kunden, setKunden] = useState([]); const [artikel, setArtikel] = useState([]); const [kundeId, setKundeId] = useState(''); - const [artikelId, setArtikelId] = useState(''); - const [anzahl, setAnzahl] = useState(1); + const [rows, setRows] = useState([newRow()]); const [foto, setFoto] = useState(null); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(''); + const [msgKind, setMsgKind] = useState(''); // ok | error const [infoOpen, setInfoOpen] = useState(false); const [kundenDetail, setKundenDetail] = useState(null); @@ -24,20 +26,37 @@ export default function VorgangForm({ onSubmit }) { const openInfo = async () => { if (!kundeId) return; try { setKundenDetail(await api.getKunde(kundeId)); setInfoOpen(true); } - catch (e) { setMsg(e.message); } + catch (e) { setMsg(e.message); setMsgKind('error'); } }; + const updateRow = (key, patch) => + setRows(rs => rs.map(r => r.key === key ? { ...r, ...patch } : r)); + const addRow = () => setRows(rs => [...rs, newRow()]); + const removeRow = (key) => setRows(rs => rs.length === 1 ? rs : rs.filter(r => r.key !== key)); + + const reset = () => { setRows([newRow()]); setFoto(null); }; + const submit = async (typ) => { - if (!kundeId || !artikelId) return setMsg('Bitte Kunde und Artikel wählen'); - setBusy(true); setMsg(''); + setMsg(''); setMsgKind(''); + if (!kundeId) return setMsgErr('Bitte Kunde wählen'); + const positionen = rows + .filter(r => r.artikel_id && parseInt(r.anzahl) > 0) + .map(r => ({ artikel_id: r.artikel_id, anzahl: parseInt(r.anzahl) })); + if (positionen.length === 0) return setMsgErr('Mindestens eine Position mit Artikel und Anzahl'); + + setBusy(true); try { - const data = { kunde_id: kundeId, artikel_id: artikelId, anzahl: parseInt(anzahl) }; - if (typ === 'lieferung') await api.createLieferung(data, foto); - else await api.createRuecknahme(data, foto); - setMsg('Gespeichert.'); setKundeId(''); setArtikelId(''); setAnzahl(1); setFoto(null); + const res = typ === 'lieferung' + ? await api.createLieferungBulk(kundeId, positionen, foto) + : await api.createRuecknahmeBulk(kundeId, positionen, foto); + setMsg(`${res.count} Position${res.count === 1 ? '' : 'en'} gespeichert.`); + setMsgKind('ok'); + reset(); onSubmit?.(); - } catch (e) { setMsg(e.message); } finally { setBusy(false); } + } catch (e) { setMsgErr(e.message); } + finally { setBusy(false); } }; + const setMsgErr = (m) => { setMsg(m); setMsgKind('error'); }; return (
@@ -56,31 +75,53 @@ export default function VorgangForm({ onSubmit }) {
- - -
- -
- - setAnzahl(e.target.value)} /> +
+ + +
+
+ {rows.map((r, idx) => ( +
+ + updateRow(r.key, { anzahl: e.target.value })} /> + +
+ ))} +
setFoto(e.target.files?.[0] || null)} />
- {msg &&
{msg}
} + {msg && ( +
+ {msg} +
+ )}
- +
{infoOpen && kundenDetail && (