Import/Export von Bestaenden (CSV + JSON), rein additiv
Export:
- GET /export/stock.csv - eine Zeile je Charge, Semikolon-getrennt mit BOM,
direkt in Excel/LibreOffice bearbeitbar; Mengen in der Artikeleinheit.
- GET /export/backup.json - vollstaendiges Backup (Einheiten, Gruppen,
Lagerorte, Produkte, Chargen), Referenzen ueber Namen statt IDs.
Import:
- POST /import/stock (Admin, Datei-Upload). Erkennt CSV oder JSON automatisch.
Arbeitet ausschliesslich additiv: unbekannte Produkte/Gruppen/Lagerorte/
Einheiten werden angelegt, Chargen ergaenzt - nichts wird geloescht.
Mengen koennen in Gebinde ("Glas") oder Einheiten ("Gramm") angegeben werden,
Datum als YYYY-MM-DD oder TT.MM.JJJJ. Fehlerhafte Zeilen werden uebersprungen
und im Ergebnis gemeldet; jeder Import wird als Bewegung protokolliert.
Frontend: neue Seite "Import / Export" (Verwaltung) mit Download-Buttons,
Datei-Upload, Ergebniszusammenfassung und einer Beschreibung der CSV-Spalten.
api.js kann jetzt Datei-Downloads mit Auth-Header und FormData-Uploads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
139
web/src/pages/Transfer.jsx
Normal file
139
web/src/pages/Transfer.jsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
|
||||
export default function Transfer() {
|
||||
const fileRef = useRef(null);
|
||||
const [file, setFile] = useState(null);
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function download(kind) {
|
||||
setError(null);
|
||||
try {
|
||||
if (kind === "csv") await api.exportCsv();
|
||||
else await api.exportJson();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function doImport(e) {
|
||||
e.preventDefault();
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
setResult(await api.importStock(file));
|
||||
setFile(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Import / Export</h1>
|
||||
<div className="sub">Bestände samt Chargen und MHD sichern oder einspielen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
|
||||
<div className="grid-2">
|
||||
<section className="card">
|
||||
<div className="card-head"><Icon name="checkout" /><h2>Export</h2></div>
|
||||
<p className="muted small mt-0">
|
||||
<strong>CSV</strong> enthält eine Zeile je Charge und lässt sich in Excel oder
|
||||
LibreOffice bearbeiten. <strong>JSON</strong> ist ein vollständiges Backup inklusive
|
||||
Einheiten, Gruppen und Lagerorten.
|
||||
</p>
|
||||
<div className="field-inline">
|
||||
<button className="btn primary" onClick={() => download("csv")}>
|
||||
<Icon name="package" size={16} />Bestand als CSV
|
||||
</button>
|
||||
<button className="btn" onClick={() => download("json")}>
|
||||
<Icon name="box" size={16} />Backup als JSON
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<div className="card-head"><Icon name="checkin" /><h2>Import</h2></div>
|
||||
<p className="muted small mt-0">
|
||||
CSV oder JSON auswählen. Der Import <strong>ergänzt nur</strong>: unbekannte Produkte,
|
||||
Gruppen und Lagerorte werden angelegt, Chargen hinzugefügt. Es wird nichts gelöscht
|
||||
oder überschrieben.
|
||||
</p>
|
||||
<form onSubmit={doImport}>
|
||||
<label>
|
||||
Datei
|
||||
<input ref={fileRef} type="file" accept=".csv,.json,text/csv,application/json"
|
||||
onChange={(e) => setFile(e.target.files?.[0] || null)} />
|
||||
</label>
|
||||
<button className="btn primary" disabled={!file || busy}>
|
||||
{busy ? "Importiere…" : "Importieren"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{result && (
|
||||
<div className="alert ok" style={{ marginTop: "var(--sp-4)" }}>
|
||||
<Icon name="check" size={16} />
|
||||
<span>
|
||||
Import abgeschlossen ({result.format?.toUpperCase()}):{" "}
|
||||
{result.products_created} Produkt(e) angelegt, {result.lots_added} Charge(n) ergänzt
|
||||
{result.units_created ? `, ${result.units_created} Einheit(en) angelegt` : ""}.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{result?.errors?.length > 0 && (
|
||||
<div className="alert warn">
|
||||
<Icon name="alert" size={16} />
|
||||
<span>
|
||||
{result.errors.length} Zeile(n) übersprungen:
|
||||
<ul style={{ margin: "6px 0 0", paddingLeft: 18 }}>
|
||||
{result.errors.slice(0, 10).map((e, i) => <li key={i}>{e}</li>)}
|
||||
</ul>
|
||||
{result.errors.length > 10 && <div>… und {result.errors.length - 10} weitere.</div>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="card">
|
||||
<div className="card-head"><Icon name="package" /><h2>CSV-Aufbau</h2></div>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>Spalte</th><th>Bedeutung</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td className="strong">barcode</td><td>optional; dient zum Wiedererkennen vorhandener Produkte</td></tr>
|
||||
<tr><td className="strong">name</td><td>Produktname (Pflicht, wenn kein Barcode passt)</td></tr>
|
||||
<tr><td className="strong">marke</td><td>optional</td></tr>
|
||||
<tr><td className="strong">einheit</td><td>Produkteinheit, z.B. Gramm, Kilogramm, Stück</td></tr>
|
||||
<tr><td className="strong">packungsgroesse</td><td>Basiseinheiten je Gebinde, z.B. 195</td></tr>
|
||||
<tr><td className="strong">gebinde</td><td>Bezeichnung, z.B. Glas, Tüte, Flasche</td></tr>
|
||||
<tr><td className="strong">gruppe</td><td>optional; wird bei Bedarf angelegt</td></tr>
|
||||
<tr><td className="strong">mindestbestand</td><td>optional, in Basiseinheiten</td></tr>
|
||||
<tr><td className="strong">menge</td><td>Menge dieser Charge</td></tr>
|
||||
<tr><td className="strong">menge_einheit</td><td>Einheit der Menge: Gebinde-Bezeichnung oder z.B. Gramm</td></tr>
|
||||
<tr><td className="strong">mhd</td><td>Datum, z.B. 2026-12-23 oder 23.12.2026 – leer erlaubt</td></tr>
|
||||
<tr><td className="strong">lagerort</td><td>optional; wird bei Bedarf angelegt</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="muted small">
|
||||
Am einfachsten: einmal exportieren, die Datei als Vorlage nehmen und ergänzen.
|
||||
Trennzeichen ist ein Semikolon; Komma wird ebenfalls erkannt.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user