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:
@@ -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)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 & 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">
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user