Etiketten-Vorlagen: gespeicherte Exporte mit frei waehlbaren Spalten
Der Etikettenexport konnte genau eine Sache, und zwar unveraenderlich: sechs
feste Spalten ueber Einzelstuecke. Was fehlte, war genau das, was ein Etikett
brauchbar macht - die eigenen Felder. Eine Kabellaenge oder die Kapazitaet einer
Powerbank steht laengst am Artikel, kam aber in keiner CSV vor. Und jede
Zusammenstellung war beim naechsten Mal wieder weg: Kategorien im React-State,
Trennzeichen im localStorage, Spalten hartkodiert.
Jetzt gibt es benannte Vorlagen mit eigener Spaltenwahl und Reihenfolge, fuer
Einzelstuecke, Artikel und Lagerorte. Eine Tabelle nach dem Vorbild von
DashboardLayout: benannt, Nutzlast als JSON-Text. Ohne user_id - eine Vorlage
gehoert allen, nicht einem Konto.
Spaltenschluessel fuer eigene Felder ist die ID, nicht der key: FieldDefinition.key
ist nur JE KATEGORIE eindeutig, zwei Kategorien duerfen beide "kapazitaet" fuehren.
Die CSV entsteht jetzt im Server statt im Browser. Mit waehlbaren Spalten,
eigenen Feldern und drei Bezugsarten gehoert das an eine Stelle - und zwar
dorthin, wo es sich pruefen laesst. Formel-Injektionsschutz, BOM und CRLF sind
aus der Browser-Fassung mitgenommen. Der Grund, aus dem der QR-Inhalt bisher im
Browser entstand ("nur die Oberflaeche kennt die oeffentliche Adresse"), gilt
allein fuer Lagerorte; dafuer reicht sie ihre Herkunft als Parameter durch.
Die beiden alten Abschnitte weichen den Vorlagen. Beim ersten Start entstehen
zwei mitgelieferte, die genau die bisherigen Spalten fuehren - wer nichts
einstellt, bekommt dasselbe Ergebnis wie vorher.
Dazu laesst sich eine P-touch-Datei hinterlegen. Geprueft wird ueber Endung und
ZIP-Inhalt (label.xml), nicht ueber den Content-Type: Fuer .lbx meldet der
Browser meist application/octet-stream. Eine .lbx merkt sich den Pfad zu ihrer
Datenquelle - auf einem fremden Rechner muss die CSV in P-touch einmal neu
verknuepft werden. Das laesst sich von hier aus nicht loesen; die Datei
dabeizuhaben erspart trotzdem das Suchen.
Sicherung auf Version 4: Vorlagen kommen mit, Kategorien als Pfad und eigene
Felder als Label, weil IDs zwischen zwei Instanzen nicht dieselben sind. Die
.lbx als Base64 - das erste Binaerformat in dieser Sicherung, vertretbar bei
wenigen Kilobyte und einer Vorlage, die ohne ihre Datei nur eine halbe waere.
23 neue Tests (343 gesamt), dazu im Browser durchgespielt: Kabel-Kategorie mit
eigenem Feld, Spalte hinzugefuegt, CSV mit "Länge (m)" und dem Wert, .lbx
hoch- und wieder heruntergeladen (Pruefsumme identisch), PNG und Nicht-ZIP
sauber abgelehnt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7372590171
commit
c741df5963
@@ -359,6 +359,36 @@ export const api = {
|
||||
exportJson: () =>
|
||||
downloadFile("/export/backup.json", `vorrania-backup_${zeitstempel()}.json`),
|
||||
// Zeilen für den QR-Etiketten-Export (P-touch); QR-Inhalt setzt das Web dazu.
|
||||
// ---- Etiketten-Vorlagen ----
|
||||
etikettenVorlagen: () => request("/etiketten/vorlagen"),
|
||||
etikettenSpalten: (art, categoryIds) => {
|
||||
const p = new URLSearchParams({ art });
|
||||
if (categoryIds && categoryIds.length) p.set("kategorie_ids", categoryIds.join(","));
|
||||
return request(`/etiketten/spalten?${p}`);
|
||||
},
|
||||
etikettenVorlageAnlegen: (body) =>
|
||||
request("/etiketten/vorlagen", { method: "POST", body }),
|
||||
etikettenVorlageAendern: (id, body) =>
|
||||
request(`/etiketten/vorlagen/${id}`, { method: "PATCH", body }),
|
||||
etikettenVorlageLoeschen: (id) =>
|
||||
request(`/etiketten/vorlagen/${id}`, { method: "DELETE" }),
|
||||
// Die CSV baut der Server. basis_url braucht nur der Lagerort-QR (…/l/<id>);
|
||||
// nur der Browser weiss, unter welcher Adresse er gerade erreichbar ist.
|
||||
etikettenCsv: (id, name) =>
|
||||
downloadFile(
|
||||
`/etiketten/vorlagen/${id}/csv?basis_url=${encodeURIComponent(window.location.origin)}`,
|
||||
`${name || "etiketten"}.csv`,
|
||||
),
|
||||
etikettenDateiHochladen: (id, file) => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
return request(`/etiketten/vorlagen/${id}/datei`, { method: "POST", formData: fd });
|
||||
},
|
||||
etikettenDatei: (id, name) =>
|
||||
downloadFile(`/etiketten/vorlagen/${id}/datei`, name || "vorlage.lbx"),
|
||||
etikettenDateiEntfernen: (id) =>
|
||||
request(`/etiketten/vorlagen/${id}/datei`, { method: "DELETE" }),
|
||||
|
||||
labelRows: (categoryIds) =>
|
||||
request(`/export/labels${categoryIds && categoryIds.length ? `?category_ids=${categoryIds.join(",")}` : ""}`),
|
||||
importStock: (file, mode = "add") => {
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import Icon from "./Icon";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { useToast } from "../toast";
|
||||
import { asTree } from "../categoryTree";
|
||||
|
||||
/**
|
||||
* Gespeicherte Zusammenstellungen für den Etikettenexport.
|
||||
*
|
||||
* Ersetzt die beiden früheren Abschnitte mit festen Spalten. Der Gewinn sind
|
||||
* nicht die Vorlagen an sich, sondern die frei wählbaren Spalten: Erst dadurch
|
||||
* kommen eigene Felder wie eine Kabellänge oder die Kapazität einer Powerbank
|
||||
* überhaupt aufs Etikett.
|
||||
*
|
||||
* Die CSV baut der Server – dort liegen die Feldwerte, und dort lässt sich das
|
||||
* Ergebnis prüfen.
|
||||
*/
|
||||
|
||||
const ARTEN = [
|
||||
{ wert: "items", label: "Einzelstücke", hinweis: "Ein Etikett je Stück, mit UID und QR." },
|
||||
{ wert: "products", label: "Artikel", hinweis: "Ein Etikett je Artikel statt je Stück." },
|
||||
{ wert: "locations", label: "Lagerorte", hinweis: "Ein Etikett je Lagerort, mit dessen QR." },
|
||||
];
|
||||
|
||||
const TRENNZEICHEN = [
|
||||
{ wert: ",", label: "Komma" },
|
||||
{ wert: ";", label: "Semikolon" },
|
||||
{ wert: "\t", label: "Tabulator" },
|
||||
];
|
||||
|
||||
export default function EtikettenVorlagen({ onError }) {
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
const [vorlagen, setVorlagen] = useState(null);
|
||||
const [offen, setOffen] = useState(null); // id der bearbeiteten Vorlage
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const laden = useCallback(async () => {
|
||||
try { setVorlagen(await api.etikettenVorlagen()); }
|
||||
catch (err) { onError(err.message); setVorlagen([]); }
|
||||
}, [onError]);
|
||||
|
||||
useEffect(() => { laden(); }, [laden]);
|
||||
|
||||
async function anlegen() {
|
||||
const name = await confirm({
|
||||
title: "Neue Vorlage",
|
||||
message: "Wie soll sie heißen? Zum Beispiel „Kabelexport“.",
|
||||
confirmLabel: "Anlegen",
|
||||
prompt: true,
|
||||
});
|
||||
if (!name || typeof name !== "string") return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const neu = await api.etikettenVorlageAnlegen({
|
||||
name: name.trim(),
|
||||
art: "items",
|
||||
spalten: ["qr", "uid", "produkt"],
|
||||
kategorie_ids: [],
|
||||
trennzeichen: ",",
|
||||
});
|
||||
await laden();
|
||||
setOffen(neu.id);
|
||||
toast(`Vorlage „${neu.name}“ angelegt.`);
|
||||
} catch (err) { onError(err.message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function loeschen(v) {
|
||||
const ok = await confirm({
|
||||
title: `Vorlage „${v.name}“ löschen?`,
|
||||
message: "Die hinterlegte P-touch-Datei geht mit verloren.",
|
||||
confirmLabel: "Löschen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.etikettenVorlageLoeschen(v.id);
|
||||
if (offen === v.id) setOffen(null);
|
||||
await laden();
|
||||
} catch (err) { onError(err.message); }
|
||||
}
|
||||
|
||||
async function csv(v) {
|
||||
setBusy(true);
|
||||
try { await api.etikettenCsv(v.id, v.name); }
|
||||
catch (err) { onError(err.message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="card-head">
|
||||
<Icon name="download" /><h2>Etiketten-Vorlagen</h2>
|
||||
<button type="button" className="btn sm" style={{ marginLeft: "auto" }}
|
||||
disabled={busy} onClick={anlegen}>
|
||||
<Icon name="plus" size={14} />Neue Vorlage
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted small">
|
||||
Eine Vorlage hält fest, worauf sie sich bezieht, welche Spalten in welcher
|
||||
Reihenfolge exportiert werden und wie gefiltert wird – samt eigener Felder.
|
||||
Dazu lässt sich die P-touch-Datei (.lbx) hinterlegen.
|
||||
</p>
|
||||
|
||||
{vorlagen == null && <div className="muted">Lädt…</div>}
|
||||
{vorlagen && vorlagen.length === 0 && (
|
||||
<p className="muted small">Noch keine Vorlage.</p>
|
||||
)}
|
||||
|
||||
<ul className="clean-list" style={{ marginTop: "var(--sp-3)" }}>
|
||||
{(vorlagen || []).map((v) => (
|
||||
<li key={v.id} style={{ flexDirection: "column", alignItems: "stretch" }}>
|
||||
<div className="cell-row" style={{ gap: "var(--sp-2)" }}>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span className="strong">{v.name}</span>{" "}
|
||||
<span className="muted small">
|
||||
{ARTEN.find((a) => a.wert === v.art)?.label || v.art}
|
||||
{" · "}{v.spalten.length} Spalten
|
||||
{v.datei_name ? " · mit Vorlage" : ""}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" className="btn sm" disabled={busy}
|
||||
onClick={() => csv(v)}>
|
||||
<Icon name="download" size={14} />CSV
|
||||
</button>
|
||||
{v.datei_name && (
|
||||
<button type="button" className="btn sm ghost"
|
||||
onClick={() => api.etikettenDatei(v.id, v.datei_name).catch((e) => onError(e.message))}>
|
||||
<Icon name="download" size={14} />.lbx
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn-icon"
|
||||
title={offen === v.id ? "Zuklappen" : "Bearbeiten"}
|
||||
onClick={() => setOffen(offen === v.id ? null : v.id)}>
|
||||
<Icon name="edit" size={16} />
|
||||
</button>
|
||||
<button type="button" className="btn-icon danger" title="Löschen"
|
||||
onClick={() => loeschen(v)}>
|
||||
<Icon name="trash" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{offen === v.id && (
|
||||
<VorlageEditor vorlage={v} onGespeichert={laden} onError={onError} />
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bearbeiten einer einzelnen Vorlage. */
|
||||
function VorlageEditor({ vorlage, onGespeichert, onError }) {
|
||||
const toast = useToast();
|
||||
const [art, setArt] = useState(vorlage.art);
|
||||
const [spalten, setSpalten] = useState(vorlage.spalten);
|
||||
const [katIds, setKatIds] = useState(vorlage.kategorie_ids);
|
||||
const [trenn, setTrenn] = useState(vorlage.trennzeichen);
|
||||
const [moeglich, setMoeglich] = useState([]);
|
||||
const [kategorien, setKategorien] = useState([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.listCategories().then(setKategorien).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Welche Spalten es gibt, hängt von Bezugsart UND Kategorien ab – die eigenen
|
||||
// Felder kommen schließlich von dort.
|
||||
useEffect(() => {
|
||||
let aktiv = true;
|
||||
api.etikettenSpalten(art, katIds)
|
||||
.then((s) => { if (aktiv) setMoeglich(s); })
|
||||
.catch((err) => onError(err.message));
|
||||
return () => { aktiv = false; };
|
||||
}, [art, katIds, onError]);
|
||||
|
||||
const titelVon = (schluessel) =>
|
||||
moeglich.find((m) => m.schluessel === schluessel)?.titel || schluessel;
|
||||
const offen = moeglich.filter((m) => !spalten.includes(m.schluessel));
|
||||
|
||||
function verschieben(index, richtung) {
|
||||
const ziel = index + richtung;
|
||||
if (ziel < 0 || ziel >= spalten.length) return;
|
||||
const kopie = [...spalten];
|
||||
[kopie[index], kopie[ziel]] = [kopie[ziel], kopie[index]];
|
||||
setSpalten(kopie);
|
||||
}
|
||||
|
||||
async function speichern() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.etikettenVorlageAendern(vorlage.id, {
|
||||
art, spalten, kategorie_ids: katIds, trennzeichen: trenn,
|
||||
});
|
||||
await onGespeichert();
|
||||
toast("Vorlage gespeichert.");
|
||||
} catch (err) { onError(err.message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function dateiWaehlen(e) {
|
||||
const datei = e.target.files?.[0];
|
||||
e.target.value = ""; // damit dieselbe Datei erneut gewählt werden kann
|
||||
if (!datei) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.etikettenDateiHochladen(vorlage.id, datei);
|
||||
await onGespeichert();
|
||||
toast("P-touch-Vorlage hinterlegt.");
|
||||
} catch (err) { onError(err.message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
// Einzelstuecke gibt es nur bei Gegenstaenden; Artikel kann es in beiden
|
||||
// Welten geben, dort also nicht einschraenken.
|
||||
const baum = asTree(kategorien).filter(
|
||||
(c) => art === "products" || c.tracking === "object",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card-sub" style={{ marginTop: "var(--sp-3)" }}>
|
||||
<div className="row">
|
||||
<label className="grow" style={{ maxWidth: 260 }}>
|
||||
Bezugsart
|
||||
<select value={art} onChange={(e) => { setArt(e.target.value); setSpalten([]); }}>
|
||||
{ARTEN.map((a) => <option key={a.wert} value={a.wert}>{a.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label style={{ maxWidth: 200 }}>
|
||||
Trennzeichen
|
||||
<select value={trenn} onChange={(e) => setTrenn(e.target.value)}>
|
||||
{TRENNZEICHEN.map((t) => (
|
||||
<option key={t.wert} value={t.wert}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="muted small mt-0">{ARTEN.find((a) => a.wert === art)?.hinweis}</p>
|
||||
|
||||
{art !== "locations" && (
|
||||
<>
|
||||
<span className="muted small">Kategorien (leer = alle)</span>
|
||||
<div className="kat-check-liste" style={{ marginTop: "var(--sp-1)" }}>
|
||||
{baum.map((c) => (
|
||||
<label key={c.id} className="check-inline"
|
||||
style={{ margin: 0, paddingLeft: (c.depth || 0) * 16 }}>
|
||||
<input type="checkbox" checked={katIds.includes(c.id)}
|
||||
onChange={(e) => setKatIds(e.target.checked
|
||||
? [...katIds, c.id]
|
||||
: katIds.filter((k) => k !== c.id))} />
|
||||
<span>{c.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="muted small">
|
||||
Eigene Felder hängen am Artikel, nicht am einzelnen Stück – alle Stücke
|
||||
eines Artikels tragen denselben Wert.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid-2" style={{ marginTop: "var(--sp-3)" }}>
|
||||
<div>
|
||||
<span className="muted small">Spalten (in dieser Reihenfolge)</span>
|
||||
{spalten.length === 0 && (
|
||||
<p className="muted small">Noch keine gewählt – es greifen dann die Standardspalten.</p>
|
||||
)}
|
||||
<ul className="clean-list" style={{ marginTop: "var(--sp-2)" }}>
|
||||
{spalten.map((s, i) => (
|
||||
<li key={s}>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>{titelVon(s)}</span>
|
||||
<button type="button" className="btn-icon" title="Nach oben"
|
||||
disabled={i === 0} onClick={() => verschieben(i, -1)}>↑</button>
|
||||
<button type="button" className="btn-icon" title="Nach unten"
|
||||
disabled={i === spalten.length - 1} onClick={() => verschieben(i, 1)}>↓</button>
|
||||
<button type="button" className="btn-icon danger" title="Entfernen"
|
||||
onClick={() => setSpalten(spalten.filter((x) => x !== s))}>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted small">Verfügbar</span>
|
||||
{offen.length === 0 && <p className="muted small">Alle Spalten sind gewählt.</p>}
|
||||
<ul className="clean-list" style={{ marginTop: "var(--sp-2)" }}>
|
||||
{offen.map((m) => (
|
||||
<li key={m.schluessel}>
|
||||
<button type="button" className="btn sm ghost"
|
||||
onClick={() => setSpalten([...spalten, m.schluessel])}>
|
||||
<Icon name="plus" size={14} />{m.titel}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ marginTop: "var(--sp-3)", alignItems: "center" }}>
|
||||
<button type="button" className="btn primary" disabled={busy} onClick={speichern}>
|
||||
{busy ? "Speichern…" : "Speichern"}
|
||||
</button>
|
||||
<label style={{ margin: 0 }}>
|
||||
<span className="muted small">P-touch-Vorlage (.lbx)</span>
|
||||
<input type="file" accept=".lbx" onChange={dateiWaehlen} disabled={busy} />
|
||||
</label>
|
||||
{vorlage.datei_name && (
|
||||
<button type="button" className="btn sm ghost"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.etikettenDateiEntfernen(vorlage.id);
|
||||
await onGespeichert();
|
||||
} catch (err) { onError(err.message); }
|
||||
}}>
|
||||
<Icon name="trash" size={14} />{vorlage.datei_name} entfernen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="muted small">
|
||||
Eine .lbx merkt sich den Pfad zu ihrer Datenquelle. Auf einem fremden
|
||||
Rechner muss die CSV in P-touch einmal neu verknüpft werden – die Datei
|
||||
dabeizuhaben erspart trotzdem das Suchen.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { api } from "../api";
|
||||
import { useConfirm } from "../confirm";
|
||||
import Icon from "../components/Icon";
|
||||
import { asTree } from "../categoryTree";
|
||||
import EtikettenVorlagen from "../components/EtikettenVorlagen";
|
||||
import { itemDeepLink } from "../qr";
|
||||
|
||||
// Baut die Etiketten-CSV mit wählbarem Trennzeichen (BOM für Excel-Umlaute).
|
||||
@@ -362,83 +363,7 @@ export default function Transfer() {
|
||||
{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>
|
||||
<EtikettenVorlagen onError={setError} />
|
||||
|
||||
<section className="card">
|
||||
<div className="card-head"><Icon name="package" /><h2>CSV-Aufbau</h2></div>
|
||||
|
||||
Reference in New Issue
Block a user