feat(vorgang): Bulk-Erfassung mehrerer Positionen pro Vorgang
Backend:
- POST /api/lieferungen/bulk und /api/ruecknahmen/bulk
- Multipart: kunde_id, positionen (JSON-Array), foto (eins fuer alle)
- Transaktion: entweder alle Positionen oder keine
- Validierung: artikel_id + anzahl >= 1, Kunde + alle Artikel im Tenant
- Antwortet mit { count, items, foto_url }
Frontend (VorgangForm):
- Mehrere Positions-Zeilen mit + Hinzufuegen / x Entfernen
- Pro Zeile: Artikel-Dropdown + Anzahl-Input
- Ein einziger Foto-Upload fuer den gesamten Vorgang
- Erfolgs-/Fehler-Meldung als gefaerbtes Banner
- Reset nach erfolgreicher Erfassung
api/client.js: createLieferungBulk / createRuecknahmeBulk (FormData mit JSON-positionen)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
<div className="card space-y-4">
|
||||
@@ -56,31 +75,53 @@ export default function VorgangForm({ onSubmit }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Artikel</label>
|
||||
<select className="input" value={artikelId} onChange={e => setArtikelId(e.target.value)}>
|
||||
<option value="">— wählen —</option>
|
||||
{artikel.map(a => <option key={a.id} value={a.id}>{a.bezeichnung}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Anzahl</label>
|
||||
<input type="number" min="1" className="input" value={anzahl} onChange={e => setAnzahl(e.target.value)} />
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="label !mb-0">Positionen</label>
|
||||
<button type="button" onClick={addRow} className="text-xs text-brand-700 hover:text-brand-900 inline-flex items-center gap-1">
|
||||
<FaPlus size={10} /> Position hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{rows.map((r, idx) => (
|
||||
<div key={r.key} className="flex gap-2">
|
||||
<select className="input flex-1" value={r.artikel_id}
|
||||
onChange={e => updateRow(r.key, { artikel_id: e.target.value })}>
|
||||
<option value="">— Artikel wählen —</option>
|
||||
{artikel.map(a => <option key={a.id} value={a.id}>{a.bezeichnung}</option>)}
|
||||
</select>
|
||||
<input type="number" min="1" className="input w-24" value={r.anzahl}
|
||||
onChange={e => updateRow(r.key, { anzahl: e.target.value })} />
|
||||
<button type="button" onClick={() => removeRow(r.key)}
|
||||
disabled={rows.length === 1}
|
||||
className="btn-ghost px-3 text-rose-600 disabled:text-slate-300"
|
||||
title="Position entfernen">
|
||||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input type="file" accept="image/*" id="foto" className="sr-only"
|
||||
onChange={e => setFoto(e.target.files?.[0] || null)} />
|
||||
<label htmlFor="foto" className="btn-secondary w-full cursor-pointer">
|
||||
<FaCamera /> {foto ? foto.name : 'Foto erstellen / auswählen'}
|
||||
<FaCamera /> {foto ? foto.name : 'Foto (für den gesamten Vorgang)'}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{msg && <div className="text-sm text-brand-700 text-center">{msg}</div>}
|
||||
{msg && (
|
||||
<div className={`text-sm text-center rounded-md px-3 py-2 ring-1 ${
|
||||
msgKind === 'ok' ? 'bg-emerald-50 text-emerald-700 ring-emerald-200' :
|
||||
msgKind === 'error' ? 'bg-rose-50 text-rose-700 ring-rose-200' :
|
||||
'bg-slate-50 text-slate-700 ring-slate-200'}`}>
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
<button disabled={busy} onClick={() => submit('lieferung')} className="btn-primary">Ausliefern</button>
|
||||
<button disabled={busy} onClick={() => submit('rücknahme')} className="btn-secondary">Zurücknehmen</button>
|
||||
<button disabled={busy} onClick={() => submit('ruecknahme')} className="btn-secondary">Zurücknehmen</button>
|
||||
</div>
|
||||
|
||||
{infoOpen && kundenDetail && (
|
||||
|
||||
Reference in New Issue
Block a user