Zellen, die mit = + - @ (oder Tab/CR) beginnen, werden mit einem vorangestellten Apostroph zu Text entschaerft. So fuehrt ein Produktname wie "=HYPERLINK(...)" beim Oeffnen der Etiketten-CSV in Excel/P-touch nicht mehr als Formel aus. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
285 lines
12 KiB
JavaScript
285 lines
12 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");
|
||
}
|
||
|
||
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") || ",");
|
||
|
||
useEffect(() => { api.listCategories().then(setCategories).catch(() => {}); }, []);
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
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="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. Kategorien wählen
|
||
(Unterkategorien sind enthalten); ohne Auswahl kommen alle Einzelstücke.
|
||
</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">
|
||
{asTree(categories).filter((c) => c.tracking === "object").map((c) => (
|
||
<label key={c.id} className="check-inline" style={{ paddingLeft: c.depth * 18 }}>
|
||
<input type="checkbox" checked={selCats.has(c.id)}
|
||
onChange={(e) => toggleCat(c.id, e.target.checked)} />
|
||
<span>{c.name}</span>
|
||
</label>
|
||
))}
|
||
{asTree(categories).filter((c) => c.tracking === "object").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="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>
|
||
);
|
||
}
|