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' }); }
|
} 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;
|
export default router;
|
||||||
|
|||||||
@@ -73,4 +73,65 @@ router.delete('/:id', async (req, res) => {
|
|||||||
} catch (e) { res.status(500).json({ error: 'Serverfehler' }); }
|
} 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;
|
export default router;
|
||||||
|
|||||||
@@ -149,6 +149,26 @@ class ApiClient {
|
|||||||
createTenant(t) { return this.request('/tenants', { method: 'POST', body: JSON.stringify(t) }); }
|
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) }); }
|
updateTenant(id, t) { return this.request(`/tenants/${id}`, { method: 'PATCH', body: JSON.stringify(t) }); }
|
||||||
deleteTenant(id) { return this.request(`/tenants/${id}`, { method: 'DELETE' }); }
|
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();
|
export const api = new ApiClient();
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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';
|
import { api } from '../../api/client';
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_URL || '/api';
|
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 }) {
|
export default function VorgangForm({ onSubmit }) {
|
||||||
const [kunden, setKunden] = useState([]);
|
const [kunden, setKunden] = useState([]);
|
||||||
const [artikel, setArtikel] = useState([]);
|
const [artikel, setArtikel] = useState([]);
|
||||||
const [kundeId, setKundeId] = useState('');
|
const [kundeId, setKundeId] = useState('');
|
||||||
const [artikelId, setArtikelId] = useState('');
|
const [rows, setRows] = useState([newRow()]);
|
||||||
const [anzahl, setAnzahl] = useState(1);
|
|
||||||
const [foto, setFoto] = useState(null);
|
const [foto, setFoto] = useState(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
|
const [msgKind, setMsgKind] = useState(''); // ok | error
|
||||||
const [infoOpen, setInfoOpen] = useState(false);
|
const [infoOpen, setInfoOpen] = useState(false);
|
||||||
const [kundenDetail, setKundenDetail] = useState(null);
|
const [kundenDetail, setKundenDetail] = useState(null);
|
||||||
|
|
||||||
@@ -24,20 +26,37 @@ export default function VorgangForm({ onSubmit }) {
|
|||||||
const openInfo = async () => {
|
const openInfo = async () => {
|
||||||
if (!kundeId) return;
|
if (!kundeId) return;
|
||||||
try { setKundenDetail(await api.getKunde(kundeId)); setInfoOpen(true); }
|
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) => {
|
const submit = async (typ) => {
|
||||||
if (!kundeId || !artikelId) return setMsg('Bitte Kunde und Artikel wählen');
|
setMsg(''); setMsgKind('');
|
||||||
setBusy(true); setMsg('');
|
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 {
|
try {
|
||||||
const data = { kunde_id: kundeId, artikel_id: artikelId, anzahl: parseInt(anzahl) };
|
const res = typ === 'lieferung'
|
||||||
if (typ === 'lieferung') await api.createLieferung(data, foto);
|
? await api.createLieferungBulk(kundeId, positionen, foto)
|
||||||
else await api.createRuecknahme(data, foto);
|
: await api.createRuecknahmeBulk(kundeId, positionen, foto);
|
||||||
setMsg('Gespeichert.'); setKundeId(''); setArtikelId(''); setAnzahl(1); setFoto(null);
|
setMsg(`${res.count} Position${res.count === 1 ? '' : 'en'} gespeichert.`);
|
||||||
|
setMsgKind('ok');
|
||||||
|
reset();
|
||||||
onSubmit?.();
|
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 (
|
return (
|
||||||
<div className="card space-y-4">
|
<div className="card space-y-4">
|
||||||
@@ -56,31 +75,53 @@ export default function VorgangForm({ onSubmit }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Artikel</label>
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<select className="input" value={artikelId} onChange={e => setArtikelId(e.target.value)}>
|
<label className="label !mb-0">Positionen</label>
|
||||||
<option value="">— wählen —</option>
|
<button type="button" onClick={addRow} className="text-xs text-brand-700 hover:text-brand-900 inline-flex items-center gap-1">
|
||||||
{artikel.map(a => <option key={a.id} value={a.id}>{a.bezeichnung}</option>)}
|
<FaPlus size={10} /> Position hinzufügen
|
||||||
</select>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
<div>
|
{rows.map((r, idx) => (
|
||||||
<label className="label">Anzahl</label>
|
<div key={r.key} className="flex gap-2">
|
||||||
<input type="number" min="1" className="input" value={anzahl} onChange={e => setAnzahl(e.target.value)} />
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<input type="file" accept="image/*" id="foto" className="sr-only"
|
<input type="file" accept="image/*" id="foto" className="sr-only"
|
||||||
onChange={e => setFoto(e.target.files?.[0] || null)} />
|
onChange={e => setFoto(e.target.files?.[0] || null)} />
|
||||||
<label htmlFor="foto" className="btn-secondary w-full cursor-pointer">
|
<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>
|
</label>
|
||||||
</div>
|
</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">
|
<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('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>
|
</div>
|
||||||
|
|
||||||
{infoOpen && kundenDetail && (
|
{infoOpen && kundenDetail && (
|
||||||
|
|||||||
Reference in New Issue
Block a user