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 (

Import / Export

Bestände samt Chargen und MHD sichern oder einspielen
{error &&
{error}
}

Export

CSV enthält eine Zeile je Charge und lässt sich in Excel oder LibreOffice bearbeiten. JSON ist ein vollständiges Backup inklusive Einheiten, Gruppen und Lagerorten.

Import

CSV oder JSON auswählen. Unbekannte Produkte, Gruppen und Lagerorte werden immer angelegt – was mit vorhandenen Beständen passiert, bestimmt der Modus.

{MODES[mode]}

{result && (
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` : ""}.
)} {result?.errors?.length > 0 && (
{result.errors.length} Zeile(n) übersprungen:
    {result.errors.slice(0, 10).map((e, i) =>
  • {e}
  • )}
{result.errors.length > 10 &&
… und {result.errors.length - 10} weitere.
}
)}

Stammdaten sichern & wiederherstellen (JSON)

Kategorien (inkl. eigener Felder), Lagerorte, Einheiten und Gebinde als eine JSON-Datei. Die IDs kommen mit – 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.)

{mdInfo &&

{mdInfo}

}

QR-Etiketten-Export (P-touch & Co.)

Je Einzelstück eine Zeile: QR-Inhalt, UID, Produkt, Marke, Kategorie, Lagerort. In der Etiketten-Software (z.B. P-touch) als Datenbank{" "} verknüpfen – sie erzeugt QR-Code und Text automatisch. Ohne Auswahl kommen alle Einzelstücke. Wählst du eine Kategorie, kommen ihre Unterkategorien automatisch mit – sie erscheinen unten als „enthalten".

{objTree.map((c) => { const enthalten = abgedecktVon(c.id); const direkt = selCats.has(c.id); return ( ); })} {objTree.length === 0 && ( Noch keine Gegenstands-Kategorien. )}
{selCats.size > 0 && ( )}
{labelInfo &&

{labelInfo}

}

Lagerort-Etiketten-Export (P-touch & Co.)

Je Lagerort eine Zeile: QR-Inhalt, ID, Lagerort und der{" "} Pfad (z.B. „Hedingen → Keller"). In der Etiketten-Software als{" "} Datenbank verknüpfen. Verwendet dasselbe Trennzeichen wie oben. Ein gescannter Lagerort-QR öffnet den Ort in der App/Weboberfläche.

{locations.length} Lagerort{locations.length === 1 ? "" : "e"}
{locInfo &&

{locInfo}

}

CSV-Aufbau

SpalteBedeutung
barcodeoptional; dient zum Wiedererkennen vorhandener Produkte
nameProduktname (Pflicht, wenn kein Barcode passt)
markeoptional
einheitProdukteinheit, z.B. Gramm, Kilogramm, Stück
packungsgroesseBasiseinheiten je Gebinde, z.B. 195
gebindeBezeichnung, z.B. Glas, Tüte, Flasche
gruppeoptional; wird bei Bedarf angelegt
mindestbestandoptional, in Basiseinheiten
mengeMenge dieser Charge
menge_einheitEinheit der Menge: Gebinde-Bezeichnung oder z.B. Gramm
mhdDatum, z.B. 2026-12-23 oder 23.12.2026 – leer erlaubt
lagerortoptional; wird bei Bedarf angelegt

Am einfachsten: einmal exportieren, die Datei als Vorlage nehmen und ergänzen. Trennzeichen ist ein Semikolon; Komma wird ebenfalls erkannt.

); }