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,
|
DatePrecision,
|
||||||
FieldDefinition,
|
FieldDefinition,
|
||||||
Group,
|
Group,
|
||||||
|
Item,
|
||||||
Location,
|
Location,
|
||||||
Lot,
|
Lot,
|
||||||
Movement,
|
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)
|
# Import (nur additiv)
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -249,6 +249,9 @@ export const api = {
|
|||||||
exportCsv: () => downloadFile("/export/stock.csv", `bestand_${zeitstempel()}.csv`),
|
exportCsv: () => downloadFile("/export/stock.csv", `bestand_${zeitstempel()}.csv`),
|
||||||
exportJson: () =>
|
exportJson: () =>
|
||||||
downloadFile("/export/backup.json", `vorrania-backup_${zeitstempel()}.json`),
|
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") => {
|
importStock: (file, mode = "add") => {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("file", file);
|
fd.append("file", file);
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { useConfirm } from "../confirm";
|
import { useConfirm } from "../confirm";
|
||||||
import Icon from "../components/Icon";
|
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() {
|
export default function Transfer() {
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
@@ -11,6 +27,46 @@ export default function Transfer() {
|
|||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [busy, setBusy] = useState(false);
|
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 = {
|
const MODES = {
|
||||||
add: "Nur hinzufügen – es wird nichts gelöscht.",
|
add: "Nur hinzufügen – es wird nichts gelöscht.",
|
||||||
@@ -142,6 +198,39 @@ export default function Transfer() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</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">
|
<section className="card">
|
||||||
<div className="card-head"><Icon name="package" /><h2>CSV-Aufbau</h2></div>
|
<div className="card-head"><Icon name="package" /><h2>CSV-Aufbau</h2></div>
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
|
|||||||
@@ -861,6 +861,9 @@ tr.row-active { background: var(--surface-2); }
|
|||||||
.card-sub .card-head { margin-top: 0; }
|
.card-sub .card-head { margin-top: 0; }
|
||||||
.card-sub h3 { margin: 0; font-size: 15px; }
|
.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 */
|
/* Umschalter Lebensmittel/Gegenstand */
|
||||||
.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-top: 4px; }
|
.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; }
|
.segmented button { border: 0; background: var(--surface); color: var(--text); padding: 8px 16px; cursor: pointer; font: inherit; }
|
||||||
|
|||||||
Reference in New Issue
Block a user