Web: QR-Etiketten-Export (CSV für P-touch & Co.)

Auf "Import / Export" neue Karte: Kategorien auswaehlen (inkl. Unterkategorien)
und je Einzelstueck eine CSV-Zeile herunterladen - Spalten QR-Inhalt (…/i/<UID>),
UID, Produkt, Marke, Kategorie, Lagerort. In der Etiketten-Software als Datenbank
verknuepfen, dann entsteht QR + Text automatisch - kein einzelnes Bild-Download
und Zuordnen mehr.

Backend: GET /export/labels?category_ids=... liefert die Zeilen (JSON); den
QR-Inhalt setzt das Web mit window.location.origin dazu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-26 09:22:03 +02:00
parent 7b2d4965ff
commit 2ebdf94b16
4 changed files with 138 additions and 1 deletions

View File

@@ -26,6 +26,7 @@ from ..models import (
DatePrecision,
FieldDefinition,
Group,
Item,
Location,
Lot,
Movement,
@@ -251,6 +252,47 @@ def export_backup_json(
)
@router.get("/export/labels")
def export_labels(
category_ids: str | None = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[dict]:
"""Zeilen für den QR-Etiketten-Export (P-touch & Co.) je Einzelstück eine.
``category_ids`` (z.B. "1,3") grenzt auf Kategorien samt Unterkategorien ein;
leer = alle Einzelstücke. Den QR-Inhalt (…/i/<UID>) setzt die Weboberfläche
dazu, weil nur sie die öffentliche Adresse kennt.
"""
from .categories import descendant_ids
ids: set[int] | None = None
if category_ids:
ids = set()
for part in category_ids.split(","):
teil = part.strip()
if teil.isdigit():
ids |= descendant_ids(db, int(teil))
query = db.query(Item)
if ids is not None:
query = (
query.join(Product, Item.product_id == Product.id)
.filter(Product.category_id.in_(ids))
)
rows: list[dict] = []
for item in query.order_by(Item.id).all():
product = item.product
rows.append({
"uid": item.uid,
"product": product.name if product else "",
"brand": (product.brand if product else "") or "",
"category": _category_path(db, product.category) if product else "",
"location": item.location.name if item.location else "",
})
return rows
# --------------------------------------------------------------------------
# Import (nur additiv)
# --------------------------------------------------------------------------

View File

@@ -249,6 +249,9 @@ export const api = {
exportCsv: () => downloadFile("/export/stock.csv", `bestand_${zeitstempel()}.csv`),
exportJson: () =>
downloadFile("/export/backup.json", `vorrania-backup_${zeitstempel()}.json`),
// Zeilen für den QR-Etiketten-Export (P-touch); QR-Inhalt setzt das Web dazu.
labelRows: (categoryIds) =>
request(`/export/labels${categoryIds && categoryIds.length ? `?category_ids=${categoryIds.join(",")}` : ""}`),
importStock: (file, mode = "add") => {
const fd = new FormData();
fd.append("file", file);

View File

@@ -1,7 +1,23 @@
import { useRef, useState } from "react";
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 (Semikolon, BOM für Excel). QR-Inhalt = Link aufs Stück.
function buildLabelCsv(rows, origin) {
const head = ["QR-Inhalt", "UID", "Produkt", "Marke", "Kategorie", "Lagerort"];
const esc = (v) => {
const s = String(v ?? "");
return /[";\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const lines = [head.join(";")];
for (const r of rows) {
lines.push([`${origin}/i/${r.uid}`, r.uid, r.product, r.brand, r.category, r.location]
.map(esc).join(";"));
}
return "" + lines.join("\r\n");
}
export default function Transfer() {
const confirm = useConfirm();
@@ -11,6 +27,46 @@ export default function Transfer() {
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);
useEffect(() => { api.listCategories().then(setCategories).catch(() => {}); }, []);
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);
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.",
@@ -142,6 +198,39 @@ export default function Transfer() {
</section>
</div>
<section className="card">
<div className="card-head"><Icon name="tag" /><h2>QR-Etiketten-Export (P-touch &amp; 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>
<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">

View File

@@ -861,6 +861,9 @@ tr.row-active { background: var(--surface-2); }
.card-sub .card-head { margin-top: 0; }
.card-sub h3 { margin: 0; font-size: 15px; }
/* Kategorie-Auswahlliste (Etiketten-Export) */
.kat-check-liste { display: flex; flex-direction: column; gap: 4px; max-height: 260px; overflow: auto; border: 1px solid var(--border); border-radius: 8px; padding: 8px; }
/* Umschalter Lebensmittel/Gegenstand */
.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-top: 4px; }
.segmented button { border: 0; background: var(--surface); color: var(--text); padding: 8px 16px; cursor: pointer; font: inherit; }