- Backend: services/master_data.py exportiert/importiert Kategorien (inkl. eigener Felder), Lagerorte, Einheiten und Gebinde als JSON – MIT IDs, damit gedruckte QR-Codes und Verweise nach dem Wiederherstellen passen. Import-Modus skip (Vorhandenes lassen) oder overwrite (per ID aktualisieren); Namens- Konflikte werden uebersprungen, nicht abgebrochen. Postgres-Sequenzen werden danach angehoben. Endpunkte GET /export/master-data, POST /import/master-data. 5 neue Tests, Suite 162 gruen. - Web: neue Karte "Stammdaten sichern & wiederherstellen (JSON)" in Import/Export. - Web: beim Lagerort-Anlegen bleibt der uebergeordnete Ort in der Auswahl. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
471 lines
20 KiB
JavaScript
471 lines
20 KiB
JavaScript
import { useEffect, useRef, useState } from "react";
|
||
import { api } from "../api";
|
||
import { useConfirm } from "../confirm";
|
||
import Icon from "../components/Icon";
|
||
import { asTree } from "../categoryTree";
|
||
|
||
// Baut die Etiketten-CSV mit wählbarem Trennzeichen (BOM für Excel-Umlaute).
|
||
// QR-Inhalt = Link aufs Stück.
|
||
function buildLabelCsv(rows, origin, delim = ",") {
|
||
const head = ["QR-Inhalt", "UID", "Produkt", "Marke", "Kategorie", "Lagerort"];
|
||
const delimRe = delim === "\t" ? "\\t" : delim;
|
||
const needsQuote = new RegExp(`[${delimRe}"\\n\\r]`);
|
||
const esc = (v) => {
|
||
let s = String(v ?? "");
|
||
// Formel-Injektion verhindern: Beginnt eine Zelle mit = + - @ (oder Tab/CR),
|
||
// koennte Excel/P-touch sie als Formel ausfuehren. Ein vorangestelltes
|
||
// Apostroph macht sie zu reinem Text.
|
||
if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
|
||
return needsQuote.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||
};
|
||
const lines = [head.map(esc).join(delim)];
|
||
for (const r of rows) {
|
||
lines.push([`${origin}/i/${r.uid}`, r.uid, r.product, r.brand, r.category, r.location]
|
||
.map(esc).join(delim));
|
||
}
|
||
return "" + lines.join("\r\n");
|
||
}
|
||
|
||
// Etiketten-CSV für Lagerorte (QR-Inhalt = Link auf den Ort). Gleiches Prinzip
|
||
// wie bei den Einzelstücken; „Pfad" nennt die Ober-Lagerorte (Hedingen → Keller).
|
||
function buildLocationCsv(locations, origin, delim = ",") {
|
||
const byId = Object.fromEntries(locations.map((l) => [l.id, l]));
|
||
const pfad = (l) => {
|
||
const teile = [l.name];
|
||
const gesehen = new Set([l.id]);
|
||
let cur = l.parent_id;
|
||
while (cur != null && byId[cur] && !gesehen.has(cur)) {
|
||
gesehen.add(cur);
|
||
teile.unshift(byId[cur].name);
|
||
cur = byId[cur].parent_id;
|
||
}
|
||
return teile.join(" → ");
|
||
};
|
||
const head = ["QR-Inhalt", "ID", "Lagerort", "Pfad"];
|
||
const delimRe = delim === "\t" ? "\\t" : delim;
|
||
const needsQuote = new RegExp(`[${delimRe}"\\n\\r]`);
|
||
const esc = (v) => {
|
||
let s = String(v ?? "");
|
||
if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
|
||
return needsQuote.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||
};
|
||
const lines = [head.map(esc).join(delim)];
|
||
for (const l of locations) {
|
||
lines.push([`${origin}/l/${l.id}`, l.id, l.name, pfad(l)].map(esc).join(delim));
|
||
}
|
||
return "" + lines.join("\r\n");
|
||
}
|
||
|
||
export default function Transfer() {
|
||
const confirm = useConfirm();
|
||
const fileRef = useRef(null);
|
||
const [file, setFile] = useState(null);
|
||
const [mode, setMode] = useState("add");
|
||
const [result, setResult] = useState(null);
|
||
const [error, setError] = useState(null);
|
||
const [busy, setBusy] = useState(false);
|
||
// Etiketten-Export: Kategorieauswahl (leer = alle).
|
||
const [categories, setCategories] = useState([]);
|
||
const [selCats, setSelCats] = useState(() => new Set());
|
||
const [labelBusy, setLabelBusy] = useState(false);
|
||
const [labelInfo, setLabelInfo] = useState(null);
|
||
// Gemerktes Trennzeichen (Standard Komma – P-touch mag das lieber).
|
||
const [labelDelim, setLabelDelim] = useState(() => localStorage.getItem("label_delim") || ",");
|
||
// Lagerort-Etiketten.
|
||
const [locations, setLocations] = useState([]);
|
||
const [locBusy, setLocBusy] = useState(false);
|
||
const [locInfo, setLocInfo] = useState(null);
|
||
// Stammdaten-Sicherung (Kategorien/Lagerorte/Einheiten/Gebinde als JSON).
|
||
const mdFileRef = useRef(null);
|
||
const [mdMode, setMdMode] = useState("skip");
|
||
const [mdBusy, setMdBusy] = useState(false);
|
||
const [mdInfo, setMdInfo] = useState(null);
|
||
|
||
useEffect(() => {
|
||
api.listCategories().then(setCategories).catch(() => {});
|
||
api.listLocations().then(setLocations).catch(() => {});
|
||
}, []);
|
||
|
||
function downloadCsv(csv, filename) {
|
||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
async function exportMasterData() {
|
||
setError(null); setMdInfo(null); setMdBusy(true);
|
||
try {
|
||
const data = await api.exportMasterData();
|
||
downloadCsv(JSON.stringify(data, null, 2), "vorrania-stammdaten.json");
|
||
setMdInfo("Stammdaten exportiert.");
|
||
} catch (err) { setError(err.message); } finally { setMdBusy(false); }
|
||
}
|
||
|
||
async function importMasterData(e) {
|
||
e.preventDefault();
|
||
const f = mdFileRef.current?.files?.[0];
|
||
if (!f) return;
|
||
setError(null); setMdInfo(null); setMdBusy(true);
|
||
try {
|
||
const data = JSON.parse(await f.text());
|
||
const rep = await api.importMasterData(data, mdMode);
|
||
const summe = (o) => Object.values(o || {}).reduce((a, b) => a + b, 0);
|
||
setMdInfo(`Import fertig: ${summe(rep.created)} angelegt, ${summe(rep.updated)} aktualisiert, ${summe(rep.skipped)} übersprungen.`);
|
||
if (mdFileRef.current) mdFileRef.current.value = "";
|
||
api.listLocations().then(setLocations).catch(() => {});
|
||
api.listCategories().then(setCategories).catch(() => {});
|
||
} catch (err) {
|
||
setError(err instanceof SyntaxError ? "Datei ist kein gültiges JSON." : err.message);
|
||
} finally { setMdBusy(false); }
|
||
}
|
||
|
||
function exportLocationLabels() {
|
||
setError(null);
|
||
setLocInfo(null);
|
||
setLocBusy(true);
|
||
try {
|
||
if (!locations.length) { setLocInfo("Keine Lagerorte vorhanden."); return; }
|
||
downloadCsv(buildLocationCsv(locations, window.location.origin, labelDelim),
|
||
"vorrania-lagerort-etiketten.csv");
|
||
setLocInfo(`${locations.length} Lagerort-Etikett(en) exportiert.`);
|
||
} catch (err) {
|
||
setError(err.message);
|
||
} finally {
|
||
setLocBusy(false);
|
||
}
|
||
}
|
||
|
||
function changeDelim(v) {
|
||
setLabelDelim(v);
|
||
localStorage.setItem("label_delim", v);
|
||
}
|
||
|
||
function toggleCat(id, on) {
|
||
setSelCats((prev) => {
|
||
const neu = new Set(prev);
|
||
if (on) neu.add(id); else neu.delete(id);
|
||
return neu;
|
||
});
|
||
}
|
||
|
||
async function exportLabels() {
|
||
setError(null);
|
||
setLabelInfo(null);
|
||
setLabelBusy(true);
|
||
try {
|
||
const rows = await api.labelRows([...selCats]);
|
||
if (!rows.length) { setLabelInfo("Keine Einzelstücke in der Auswahl."); return; }
|
||
const csv = buildLabelCsv(rows, window.location.origin, labelDelim);
|
||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = "vorrania-etiketten.csv";
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(url);
|
||
setLabelInfo(`${rows.length} Einzelstück(e) exportiert.`);
|
||
} catch (err) {
|
||
setError(err.message);
|
||
} finally {
|
||
setLabelBusy(false);
|
||
}
|
||
}
|
||
|
||
const MODES = {
|
||
add: "Nur hinzufügen – es wird nichts gelöscht.",
|
||
replace_listed:
|
||
"Bestand der Produkte ersetzen, die in der Datei vorkommen. Ideal für eine Inventur: exportieren, korrigieren, zurückspielen.",
|
||
replace_all:
|
||
"Alle Bestände vorher leeren und komplett neu aufbauen. Für die vollständige Wiederherstellung aus einem Backup.",
|
||
};
|
||
|
||
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;
|
||
if (mode !== "add") {
|
||
const warning =
|
||
mode === "replace_all"
|
||
? "ALLE Bestände werden vorher gelöscht und aus der Datei neu aufgebaut. Fortfahren?"
|
||
: "Für alle Produkte in der Datei werden die vorhandenen Chargen gelöscht und ersetzt. Fortfahren?";
|
||
if (!(await confirm({
|
||
title: "Vorhandene Bestände ersetzen?",
|
||
message: warning,
|
||
confirmLabel: "Ersetzen",
|
||
danger: true,
|
||
}))) return;
|
||
}
|
||
setError(null);
|
||
setResult(null);
|
||
setBusy(true);
|
||
try {
|
||
setResult(await api.importStock(file, mode));
|
||
setFile(null);
|
||
if (fileRef.current) fileRef.current.value = "";
|
||
} catch (err) {
|
||
setError(err.message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
// Gegenstands-Kategorien als Baum; für die Anzeige, welche Unterkategorien
|
||
// durch eine gewählte Oberkategorie automatisch mitkommen.
|
||
const objTree = asTree(categories).filter((c) => c.tracking === "object");
|
||
const parentOf = Object.fromEntries(categories.map((c) => [c.id, c.parent_id]));
|
||
const nameById = Object.fromEntries(categories.map((c) => [c.id, c.name]));
|
||
const hatKinder = (id) => objTree.some((o) => o.parent_id === id);
|
||
// Name der nächsten ausgewählten Oberkategorie (oder null) – dann ist diese
|
||
// Kategorie „über die Oberkategorie enthalten".
|
||
function abgedecktVon(id) {
|
||
let p = parentOf[id];
|
||
while (p != null) {
|
||
if (selCats.has(p)) return nameById[p];
|
||
p = parentOf[p];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
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. Unbekannte Produkte, Gruppen und Lagerorte werden immer
|
||
angelegt – was mit vorhandenen Beständen passiert, bestimmt der Modus.
|
||
</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>
|
||
<label>
|
||
Modus
|
||
<select value={mode} onChange={(e) => setMode(e.target.value)}>
|
||
<option value="add">Nur hinzufügen</option>
|
||
<option value="replace_listed">Bestände der enthaltenen Produkte ersetzen</option>
|
||
<option value="replace_all">Alle Bestände ersetzen</option>
|
||
</select>
|
||
</label>
|
||
<p className={`muted small mt-0 ${mode === "replace_all" ? "hint-danger" : ""}`}>
|
||
{MODES[mode]}
|
||
</p>
|
||
<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.products_cleared ? `, ${result.products_cleared} Produkt(e) vorher geleert` : ""}
|
||
{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="box" /><h2>Stammdaten sichern & wiederherstellen (JSON)</h2></div>
|
||
<p className="muted small mt-0">
|
||
Kategorien (inkl. eigener Felder), Lagerorte, Einheiten und Gebinde als <strong>eine
|
||
JSON-Datei</strong>. Die <strong>IDs kommen mit</strong> – so passen gedruckte
|
||
Lagerort-QR-Codes nach dem Wiederherstellen noch und Verweise bleiben gültig. Gut für
|
||
Umzug auf einen neuen Server oder als Sicherung. (Artikel/Bestände: siehe „Backup als
|
||
JSON" oben.)
|
||
</p>
|
||
<div className="grid-2">
|
||
<div>
|
||
<button className="btn primary" disabled={mdBusy} onClick={exportMasterData}>
|
||
<Icon name="download" size={16} />Stammdaten exportieren
|
||
</button>
|
||
</div>
|
||
<form onSubmit={importMasterData}>
|
||
<label>
|
||
Datei (JSON)
|
||
<input ref={mdFileRef} type="file" accept="application/json,.json" />
|
||
</label>
|
||
<label>
|
||
Bei bereits vergebener ID
|
||
<select value={mdMode} onChange={(e) => setMdMode(e.target.value)}>
|
||
<option value="skip">Vorhandenes behalten (nur Neues anlegen)</option>
|
||
<option value="overwrite">Überschreiben (per ID aktualisieren)</option>
|
||
</select>
|
||
</label>
|
||
<button className="btn" disabled={mdBusy}>
|
||
<Icon name="checkin" size={16} />{mdBusy ? "Arbeite…" : "Stammdaten importieren"}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
{mdInfo && <p className="muted small">{mdInfo}</p>}
|
||
</section>
|
||
|
||
<section className="card">
|
||
<div className="card-head"><Icon name="tag" /><h2>QR-Etiketten-Export (P-touch & Co.)</h2></div>
|
||
<p className="muted small mt-0">
|
||
Je Einzelstück eine Zeile: <strong>QR-Inhalt</strong>, UID, Produkt, Marke, Kategorie,
|
||
Lagerort. In der Etiketten-Software (z.B. P-touch) als <strong>Datenbank</strong>{" "}
|
||
verknüpfen – sie erzeugt QR-Code und Text automatisch. Ohne Auswahl kommen alle
|
||
Einzelstücke. Wählst du eine Kategorie, kommen ihre <strong>Unterkategorien
|
||
automatisch mit</strong> – sie erscheinen unten als „enthalten".
|
||
</p>
|
||
<label style={{ maxWidth: 340 }}>
|
||
Trennzeichen (für die Etiketten-Software)
|
||
<select value={labelDelim} onChange={(e) => changeDelim(e.target.value)}>
|
||
<option value=",">Komma ( , ) – z.B. P-touch</option>
|
||
<option value=";">Semikolon ( ; ) – z.B. Excel (Deutsch)</option>
|
||
<option value={"\t"}>Tabulator</option>
|
||
</select>
|
||
</label>
|
||
<div className="kat-check-liste">
|
||
{objTree.map((c) => {
|
||
const enthalten = abgedecktVon(c.id);
|
||
const direkt = selCats.has(c.id);
|
||
return (
|
||
<label key={c.id}
|
||
className={`check-inline ${enthalten ? "kat-enthalten" : ""}`}
|
||
style={{ paddingLeft: c.depth * 18 }}
|
||
title={enthalten ? `über „${enthalten}" enthalten` : undefined}>
|
||
<input type="checkbox" checked={direkt || enthalten != null}
|
||
disabled={enthalten != null}
|
||
onChange={(e) => toggleCat(c.id, e.target.checked)} />
|
||
<span>{c.name}</span>
|
||
{enthalten != null && (
|
||
<span className="badge nowrap">enthalten</span>
|
||
)}
|
||
{enthalten == null && direkt && hatKinder(c.id) && (
|
||
<span className="badge nowrap">inkl. Unterkategorien</span>
|
||
)}
|
||
{enthalten == null && !direkt && hatKinder(c.id) && (
|
||
<span className="muted small nowrap">+ Unterkategorien</span>
|
||
)}
|
||
</label>
|
||
);
|
||
})}
|
||
{objTree.length === 0 && (
|
||
<span className="muted small">Noch keine Gegenstands-Kategorien.</span>
|
||
)}
|
||
</div>
|
||
<div className="field-inline" style={{ marginTop: "var(--sp-3)" }}>
|
||
<button className="btn primary" disabled={labelBusy} onClick={exportLabels}>
|
||
<Icon name="download" size={16} />{labelBusy ? "Exportiere…" : "Etiketten-CSV herunterladen"}
|
||
</button>
|
||
{selCats.size > 0 && (
|
||
<button type="button" className="btn ghost" onClick={() => setSelCats(new Set())}>
|
||
Auswahl zurücksetzen ({selCats.size})
|
||
</button>
|
||
)}
|
||
</div>
|
||
{labelInfo && <p className="muted small">{labelInfo}</p>}
|
||
</section>
|
||
|
||
<section className="card">
|
||
<div className="card-head"><Icon name="location" /><h2>Lagerort-Etiketten-Export (P-touch & Co.)</h2></div>
|
||
<p className="muted small mt-0">
|
||
Je Lagerort eine Zeile: <strong>QR-Inhalt</strong>, ID, Lagerort und der{" "}
|
||
<strong>Pfad</strong> (z.B. „Hedingen → Keller"). In der Etiketten-Software als{" "}
|
||
<strong>Datenbank</strong> verknüpfen. Verwendet dasselbe Trennzeichen wie oben.
|
||
Ein gescannter Lagerort-QR öffnet den Ort in der App/Weboberfläche.
|
||
</p>
|
||
<div className="field-inline">
|
||
<button className="btn primary" disabled={locBusy || !locations.length} onClick={exportLocationLabels}>
|
||
<Icon name="download" size={16} />{locBusy ? "Exportiere…" : "Lagerort-Etiketten-CSV herunterladen"}
|
||
</button>
|
||
<span className="muted small" style={{ alignSelf: "center" }}>
|
||
{locations.length} Lagerort{locations.length === 1 ? "" : "e"}
|
||
</span>
|
||
</div>
|
||
{locInfo && <p className="muted small">{locInfo}</p>}
|
||
</section>
|
||
|
||
<section className="card">
|
||
<div className="card-head"><Icon name="package" /><h2>CSV-Aufbau</h2></div>
|
||
<div className="table-wrap">
|
||
<table className="table table-compact">
|
||
<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>
|
||
);
|
||
}
|