Lagerort-QR + Zuordnen per Scan
Web: druckbare QR-Etiketten fuer Lagerorte (Locations-Seite) und Route /l/<id> (zeigt den Ort im Browser). Geteilter QR-Helfer (web/src/qr.jsx). iOS: neuer Ablauf "Ort zuordnen" - erst Einzelstueck-QR (…/i/<UID>) scannen, dann Lagerort-QR (…/l/<ID>); das Stueck wird dem Ort zugewiesen, danach gleich das naechste. Plus die zuvor gebaute "Nachschlagen"-Kachel. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import Groups from "./pages/Groups";
|
||||
import Categories from "./pages/Categories";
|
||||
import Shops from "./pages/Shops";
|
||||
import ItemResolve from "./pages/ItemResolve";
|
||||
import LocationResolve from "./pages/LocationResolve";
|
||||
import Locations from "./pages/Locations";
|
||||
import PackageTypes from "./pages/PackageTypes";
|
||||
import Units from "./pages/Units";
|
||||
@@ -136,6 +137,7 @@ export default function App() {
|
||||
<Route path="/products/new" element={<Protected adminOnly><ProductForm /></Protected>} />
|
||||
<Route path="/products/:id" element={<Protected><ProductForm /></Protected>} />
|
||||
<Route path="/i/:uid" element={<Protected><ItemResolve /></Protected>} />
|
||||
<Route path="/l/:id" element={<Protected><LocationResolve /></Protected>} />
|
||||
<Route path="/groups" element={<Protected><Groups /></Protected>} />
|
||||
<Route path="/categories" element={<Protected><Categories /></Protected>} />
|
||||
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
|
||||
|
||||
38
web/src/pages/LocationResolve.jsx
Normal file
38
web/src/pages/LocationResolve.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
|
||||
/**
|
||||
* Ziel eines gescannten Lagerort-QR (/l/<id>). Im Browser zeigt es nur den
|
||||
* Namen; das eigentliche Zuordnen (Stück → Ort) läuft über die App.
|
||||
*/
|
||||
export default function LocationResolve() {
|
||||
const { id } = useParams();
|
||||
const [name, setName] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.listLocations()
|
||||
.then((ls) => {
|
||||
const l = ls.find((x) => String(x.id) === String(id));
|
||||
if (l) setName(l.name);
|
||||
else setError("Lagerort nicht gefunden.");
|
||||
})
|
||||
.catch((e) => setError(e.message));
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Lagerort {name || `#${id}`}</h1>
|
||||
<div className="sub">
|
||||
{error || "Diesen QR-Code in der App scannen (Ort zuordnen), um Einzelstücke hier einzuordnen."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { useConfirm } from "../confirm";
|
||||
import Icon from "../components/Icon";
|
||||
import { QrImg, printQrLabels } from "../qr";
|
||||
|
||||
export default function Locations() {
|
||||
const confirm = useConfirm();
|
||||
@@ -71,6 +72,12 @@ export default function Locations() {
|
||||
<h1>Lagerorte</h1>
|
||||
<div className="sub">Orte lassen sich verschachteln (z.B. Keller → Regal 2 → Fach A)</div>
|
||||
</div>
|
||||
{locations.length > 0 && (
|
||||
<button className="btn" onClick={() => printQrLabels("Lagerort-Etiketten",
|
||||
locations.map((l) => ({ text: `${window.location.origin}/l/${l.id}`, line1: l.name, line2: "Lagerort" })))}>
|
||||
<Icon name="download" size={16} />QR-Etiketten drucken
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
|
||||
@@ -108,9 +115,12 @@ export default function Locations() {
|
||||
<span className="badge">in {nameById[l.parent_id]}</span>
|
||||
)}
|
||||
</span>
|
||||
<button className="btn-icon danger" onClick={() => remove(l.id)} title="Löschen">
|
||||
<Icon name="trash" size={16} />
|
||||
</button>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<QrImg text={`${window.location.origin}/l/${l.id}`} size={44} />
|
||||
<button className="btn-icon danger" onClick={() => remove(l.id)} title="Löschen">
|
||||
<Icon name="trash" size={16} />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{locations.length === 0 && <li className="empty">Noch keine Lagerorte.</li>}
|
||||
|
||||
42
web/src/qr.jsx
Normal file
42
web/src/qr.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
/** Kleines QR-Bild für einen Wert (asynchron erzeugt). */
|
||||
export function QrImg({ text, size = 60 }) {
|
||||
const [url, setUrl] = useState(null);
|
||||
useEffect(() => {
|
||||
let ok = true;
|
||||
QRCode.toDataURL(text, { margin: 1, width: size * 2 })
|
||||
.then((u) => { if (ok) setUrl(u); })
|
||||
.catch(() => {});
|
||||
return () => { ok = false; };
|
||||
}, [text, size]);
|
||||
return url
|
||||
? <img src={url} width={size} height={size} alt="QR" style={{ display: "block" }} />
|
||||
: <span style={{ display: "inline-block", width: size, height: size }} />;
|
||||
}
|
||||
|
||||
const esc = (s) => (s || "").replace(/[<>&]/g, "");
|
||||
|
||||
/** Druckt QR-Etiketten in einem neuen Fenster. entries: [{ text, line1, line2 }]. */
|
||||
export async function printQrLabels(title, entries) {
|
||||
const labels = await Promise.all(entries.map(async (e) => {
|
||||
const url = await QRCode.toDataURL(e.text, { margin: 1, width: 260 });
|
||||
return `<div class="label"><img src="${url}"/><div class="l1">${esc(e.line1)}</div>`
|
||||
+ `<div class="l2">${esc(e.line2)}</div></div>`;
|
||||
}));
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) return false;
|
||||
w.document.write(
|
||||
`<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title><style>
|
||||
body{font-family:sans-serif;margin:8mm;display:flex;flex-wrap:wrap;gap:6mm}
|
||||
.label{width:34mm;text-align:center;border:1px solid #ccc;border-radius:3mm;padding:3mm;page-break-inside:avoid}
|
||||
.label img{width:28mm;height:28mm}
|
||||
.l1{font-weight:700;font-size:10pt;margin-top:1mm}
|
||||
.l2{font-size:8pt;color:#444}
|
||||
</style></head><body>${labels.join("")}
|
||||
<script>window.onload=function(){window.print()}</script></body></html>`,
|
||||
);
|
||||
w.document.close();
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user