From 2ebdf94b169e458c556d1be97ff1809ca732759d Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 26 Jul 2026 09:22:03 +0200 Subject: [PATCH] =?UTF-8?q?Web:=20QR-Etiketten-Export=20(CSV=20f=C3=BCr=20?= =?UTF-8?q?P-touch=20&=20Co.)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auf "Import / Export" neue Karte: Kategorien auswaehlen (inkl. Unterkategorien) und je Einzelstueck eine CSV-Zeile herunterladen - Spalten QR-Inhalt (…/i/), 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 --- backend/app/routers/transfer.py | 42 +++++++++++++++ web/src/api.js | 3 ++ web/src/pages/Transfer.jsx | 91 ++++++++++++++++++++++++++++++++- web/src/styles.css | 3 ++ 4 files changed, 138 insertions(+), 1 deletion(-) diff --git a/backend/app/routers/transfer.py b/backend/app/routers/transfer.py index 6f962ec..726d235 100644 --- a/backend/app/routers/transfer.py +++ b/backend/app/routers/transfer.py @@ -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/) 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) # -------------------------------------------------------------------------- diff --git a/web/src/api.js b/web/src/api.js index 254885b..ca8b716 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -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); diff --git a/web/src/pages/Transfer.jsx b/web/src/pages/Transfer.jsx index eb88eac..a967c10 100644 --- a/web/src/pages/Transfer.jsx +++ b/web/src/pages/Transfer.jsx @@ -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() { +
+

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. Kategorien wählen + (Unterkategorien sind enthalten); ohne Auswahl kommen alle Einzelstücke. +

+
+ {asTree(categories).filter((c) => c.tracking === "object").map((c) => ( + + ))} + {asTree(categories).filter((c) => c.tracking === "object").length === 0 && ( + Noch keine Gegenstands-Kategorien. + )} +
+
+ + {selCats.size > 0 && ( + + )} +
+ {labelInfo &&

{labelInfo}

} +
+

CSV-Aufbau

diff --git a/web/src/styles.css b/web/src/styles.css index 4b1d518..cfd52a6 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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; }