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:
Scarriffle
2026-07-22 14:17:03 +02:00
parent 2f1be8e2dd
commit 8079d2d3f0
6 changed files with 619 additions and 2 deletions

View File

@@ -13,6 +13,7 @@ import Units from "./pages/Units";
import Users from "./pages/Users";
import ShoppingList from "./pages/ShoppingList";
import History from "./pages/History";
import Transfer from "./pages/Transfer";
import Settings from "./pages/Settings";
const navClass = ({ isActive }) => (isActive ? "nav-link active" : "nav-link");
@@ -55,6 +56,7 @@ function Sidebar() {
<NavItem to="/locations" icon="location" label="Lagerorte" />
<NavItem to="/units" icon="box" label="Einheiten" />
<NavItem to="/users" icon="users" label="Benutzer" />
<NavItem to="/transfer" icon="history" label="Import / Export" />
<NavItem to="/settings" icon="settings" label="Einstellungen" />
</>
)}
@@ -108,6 +110,7 @@ export default function App() {
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
<Route path="/units" element={<Protected adminOnly><Units /></Protected>} />
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
<Route path="/transfer" element={<Protected adminOnly><Transfer /></Protected>} />
<Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -20,13 +20,16 @@ export class ApiError extends Error {
}
}
async function request(path, { method = "GET", body, form } = {}) {
async function request(path, { method = "GET", body, form, formData } = {}) {
const headers = {};
const token = getToken();
if (token) headers["Authorization"] = `Bearer ${token}`;
let payload;
if (form) {
if (formData) {
// Content-Type (inkl. boundary) setzt der Browser selbst.
payload = formData;
} else if (form) {
headers["Content-Type"] = "application/x-www-form-urlencoded";
payload = new URLSearchParams(form).toString();
} else if (body !== undefined) {
@@ -69,6 +72,26 @@ async function request(path, { method = "GET", body, form } = {}) {
return data;
}
// Datei mit Auth-Header laden und im Browser als Download anbieten.
export async function downloadFile(path, filename) {
const token = getToken();
const resp = await fetch(`${API_BASE}${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!resp.ok) {
throw new ApiError(`Download fehlgeschlagen (${resp.status})`, resp.status);
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
export const api = {
login: (username, password) =>
request("/auth/login", { method: "POST", form: { username, password } }),
@@ -107,6 +130,15 @@ export const api = {
updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }),
deleteGroup: (id) => request(`/groups/${id}`, { method: "DELETE" }),
// Export / Import
exportCsv: () => downloadFile("/export/stock.csv", "bestand.csv"),
exportJson: () => downloadFile("/export/backup.json", "project-good-backup.json"),
importStock: (file) => {
const fd = new FormData();
fd.append("file", file);
return request("/import/stock", { method: "POST", formData: fd });
},
listUnits: () => request("/units"),
createUnit: (body) => request("/units", { method: "POST", body }),
deleteUnit: (id) => request(`/units/${id}`, { method: "DELETE" }),

139
web/src/pages/Transfer.jsx Normal file
View 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>
);
}