Web: Einzelstücke mit QR – anlegen, je-Stück-Felder, Etiketten, /i/<uid>

Gegenstands-Produkte haben jetzt den Umschalter "Menge je Lagerort / Einzelstücke".
Bei Einzelstücken erscheint (volle Breite) eine Liste je Stueck mit eigener UID,
QR-Code, Lagerort, Kaufdatum, Garantie, Bezugsquelle und Notiz - direkt inline
aenderbar, mit Entfernen (Grund) und druckbaren QR-Etiketten. Ein Scan oeffnet
ueber die Route /i/<uid> das passende Stueck. QR via qrcode (gebundelt, offline).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-26 00:36:26 +02:00
parent 5ce4411d1e
commit 6fb5c47de6
7 changed files with 665 additions and 9 deletions

View File

@@ -13,6 +13,7 @@ import CheckOut from "./pages/CheckOut";
import Groups from "./pages/Groups";
import Categories from "./pages/Categories";
import Shops from "./pages/Shops";
import ItemResolve from "./pages/ItemResolve";
import Locations from "./pages/Locations";
import PackageTypes from "./pages/PackageTypes";
import Units from "./pages/Units";
@@ -134,6 +135,7 @@ export default function App() {
<Route path="/products" element={<Protected><Products /></Protected>} />
<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="/groups" element={<Protected><Groups /></Protected>} />
<Route path="/categories" element={<Protected><Categories /></Protected>} />
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />

View File

@@ -155,6 +155,14 @@ export const api = {
},
deleteProductImage: (id) => request(`/products/${id}/image`, { method: "DELETE" }),
// Einzelstücke (Items mit UID/QR)
listItems: (productId) => request(`/products/${productId}/items`),
createItems: (productId, body) => request(`/products/${productId}/items`, { method: "POST", body }),
itemByUid: (uid) => request(`/items/by-uid/${encodeURIComponent(uid)}`),
updateItem: (id, body) => request(`/items/${id}`, { method: "PATCH", body }),
removeItem: (id, body) => request(`/items/${id}/remove`, { method: "POST", body }),
deleteItem: (id) => request(`/items/${id}`, { method: "DELETE" }),
// Bestand
checkIn: (body) => request("/stock/checkin", { method: "POST", body }),
checkInBatch: (body) => request("/stock/checkin/batch", { method: "POST", body }),

View File

@@ -0,0 +1,273 @@
import { useEffect, useState } from "react";
import QRCode from "qrcode";
import { api } from "../api";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "./Icon";
import { REASONS } from "./ObjektBestand";
const reasonLabel = (v) => REASONS.find((r) => r.value === v)?.label || v;
/** Kleines QR-Bild für einen Wert (asynchron erzeugt). */
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 EMPTY_ADD = {
count: 1, location_id: "", shop_id: "", acquired_on: "", warranty_until: "", note: "",
};
/**
* Einzelstücke eines Gegenstands: jedes physische Stück mit eigener UID/QR und
* eigenen Angaben (Lagerort, gekauft am, Garantie, gekauft bei, Notiz).
*/
export default function Einzelstuecke({ product, locations, shops, isAdmin, onChanged, onError }) {
const confirm = useConfirm();
const toast = useToast();
const [items, setItems] = useState([]);
const [addForm, setAddForm] = useState(EMPTY_ADD);
const [remove, setRemove] = useState(null); // { item, reason, note } | null
const origin = window.location.origin;
async function load() {
try {
setItems(await api.listItems(product.id));
} catch (err) { onError(err.message); }
}
useEffect(() => { load(); /* eslint-disable-next-line */ }, [product.id]);
async function nachAktion() {
await load();
if (onChanged) await onChanged();
}
async function add(e) {
e.preventDefault();
try {
await api.createItems(product.id, {
count: Number(addForm.count) || 1,
location_id: addForm.location_id === "" ? null : Number(addForm.location_id),
shop_id: addForm.shop_id === "" ? null : Number(addForm.shop_id),
acquired_on: addForm.acquired_on || null,
warranty_until: addForm.warranty_until || null,
note: addForm.note || null,
});
setAddForm({ ...EMPTY_ADD, location_id: addForm.location_id, shop_id: addForm.shop_id });
await nachAktion();
toast("Einzelstück(e) angelegt.");
} catch (err) { onError(err.message); }
}
async function patchItem(item, body) {
try {
await api.updateItem(item.id, body);
await load();
} catch (err) { onError(err.message); }
}
async function doRemove(e) {
e.preventDefault();
try {
await api.removeItem(remove.item.id, { reason: remove.reason, note: remove.note || null });
setRemove(null);
await nachAktion();
toast("Einzelstück entfernt.");
} catch (err) { onError(err.message); }
}
async function loeschen(item) {
const ok = await confirm({
title: `Einzelstück ${item.uid} löschen?`,
message: "Ohne Grund nur als Korrektur. Für „kaputt/verloren“ bitte „Entfernen“.",
confirmLabel: "Löschen", danger: true,
});
if (!ok) return;
try {
await api.deleteItem(item.id);
await nachAktion();
} catch (err) { onError(err.message); }
}
async function druckeEtiketten() {
const labels = await Promise.all(items.map(async (it) => {
const url = await QRCode.toDataURL(`${origin}/i/${it.uid}`, { margin: 1, width: 260 });
const name = (product.name || "").replace(/[<>&]/g, "");
return `<div class="label"><img src="${url}"/><div class="uid">${it.uid}</div><div class="name">${name}</div></div>`;
}));
const w = window.open("", "_blank");
if (!w) { onError("Bitte Pop-ups für den Druck erlauben."); return; }
w.document.write(
`<!doctype html><html><head><meta charset="utf-8"><title>Etiketten</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}
.uid{font-weight:700;font-size:11pt;letter-spacing:1px;margin-top:1mm}
.name{font-size:8pt;color:#444}
</style></head><body>${labels.join("")}
<script>window.onload=function(){window.print()}</script></body></html>`,
);
w.document.close();
}
const ortName = (id) => (id == null ? "" : locations.find((l) => l.id === id)?.name || "");
return (
<section className="card">
<div className="card-head">
<Icon name="tag" /><h2>Einzelstücke</h2>
<span className="muted small">{items.length} Stück</span>
{items.length > 0 && (
<button type="button" className="btn sm" style={{ marginLeft: "auto" }}
onClick={druckeEtiketten}>
<Icon name="download" size={15} />Etiketten drucken
</button>
)}
</div>
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>QR</th><th>UID</th><th>Lagerort</th><th>Gekauft am</th>
<th>Garantie bis</th><th>Gekauft bei</th><th>Notiz</th><th></th>
</tr>
</thead>
<tbody>
{items.map((it) => (
<tr key={it.id}>
<td><QrImg text={`${origin}/i/${it.uid}`} size={56} /></td>
<td data-label="UID" className="strong nowrap">{it.uid}</td>
<td data-label="Lagerort">
{isAdmin ? (
<select value={it.location_id ?? ""} style={{ marginTop: 0, minWidth: 120 }}
onChange={(e) => patchItem(it, { location_id: e.target.value === "" ? null : Number(e.target.value) })}>
<option value=""> ohne </option>
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
) : <span>{ortName(it.location_id) || ""}</span>}
</td>
<td data-label="Gekauft am">
{isAdmin ? (
<input type="date" defaultValue={it.acquired_on || ""} style={{ marginTop: 0 }}
onBlur={(e) => { if ((e.target.value || "") !== (it.acquired_on || "")) patchItem(it, { acquired_on: e.target.value || null }); }} />
) : <span>{it.acquired_on || ""}</span>}
</td>
<td data-label="Garantie bis">
{isAdmin ? (
<input type="date" defaultValue={it.warranty_until || ""} style={{ marginTop: 0 }}
onBlur={(e) => { if ((e.target.value || "") !== (it.warranty_until || "")) patchItem(it, { warranty_until: e.target.value || null }); }} />
) : <span>{it.warranty_until || ""}</span>}
</td>
<td data-label="Gekauft bei">
{isAdmin ? (
<select value={it.shop_id ?? ""} style={{ marginTop: 0, minWidth: 120 }}
onChange={(e) => patchItem(it, { shop_id: e.target.value === "" ? null : Number(e.target.value) })}>
<option value=""> unbekannt </option>
{shops.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
) : <span>{it.shop_name || ""}</span>}
</td>
<td data-label="Notiz">
{isAdmin ? (
<input defaultValue={it.note || ""} placeholder="z.B. Zustand" style={{ marginTop: 0, minWidth: 120 }}
onBlur={(e) => { if ((e.target.value || "") !== (it.note || "")) patchItem(it, { note: e.target.value || null }); }} />
) : <span>{it.note || ""}</span>}
</td>
<td className="num">
{isAdmin && (
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
<button className="btn-icon" title="Mit Grund entfernen"
onClick={() => setRemove({ item: it, reason: "broken", note: "" })}>
<Icon name="checkout" size={16} />
</button>
<button className="btn-icon danger" title="Löschen (Korrektur)"
onClick={() => loeschen(it)}>
<Icon name="trash" size={16} />
</button>
</div>
)}
</td>
</tr>
))}
{items.length === 0 && <tr><td colSpan={8} className="empty">Noch keine Einzelstücke.</td></tr>}
</tbody>
</table>
</div>
{isAdmin && (
<form onSubmit={add} className="card-sub" style={{ marginTop: "var(--sp-3)" }}>
<div className="card-head"><Icon name="plus" /><h3>Einzelstücke anlegen</h3></div>
<div className="row">
<label style={{ width: 90 }}>Anzahl
<input type="number" min="1" max="200" value={addForm.count}
onChange={(e) => setAddForm({ ...addForm, count: e.target.value })} />
</label>
<label className="grow">Lagerort
<select value={addForm.location_id}
onChange={(e) => setAddForm({ ...addForm, location_id: e.target.value })}>
<option value=""> ohne </option>
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</label>
<label className="grow">Gekauft bei
<select value={addForm.shop_id}
onChange={(e) => setAddForm({ ...addForm, shop_id: e.target.value })}>
<option value=""> unbekannt </option>
{shops.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</label>
</div>
<div className="row">
<label className="grow">Gekauft am
<input type="date" value={addForm.acquired_on}
onChange={(e) => setAddForm({ ...addForm, acquired_on: e.target.value })} />
</label>
<label className="grow">Garantie bis
<input type="date" value={addForm.warranty_until}
onChange={(e) => setAddForm({ ...addForm, warranty_until: e.target.value })} />
</label>
<label className="grow">Notiz
<input value={addForm.note} placeholder="optional"
onChange={(e) => setAddForm({ ...addForm, note: e.target.value })} />
</label>
</div>
<p className="muted small">
Gemeinsame Startwerte für alle neuen Stücke danach je Stück änderbar
(z.B. dasselbe Modell 2024 und 2025). Jedes bekommt eine eigene UID + QR.
</p>
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
</form>
)}
{isAdmin && remove && (
<form onSubmit={doRemove} className="card-sub" style={{ marginTop: "var(--sp-3)" }}>
<div className="card-head"><Icon name="trash" /><h3>Einzelstück {remove.item.uid} entfernen</h3>
<button type="button" className="btn-icon" style={{ marginLeft: "auto" }}
onClick={() => setRemove(null)}><Icon name="close" size={16} /></button>
</div>
<div className="row">
<label className="grow">Grund
<select value={remove.reason} onChange={(e) => setRemove({ ...remove, reason: e.target.value })}>
{REASONS.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
</label>
<label className="grow">Notiz (optional)
<input value={remove.note} onChange={(e) => setRemove({ ...remove, note: e.target.value })} />
</label>
</div>
<button className="btn danger">Entfernen</button>
</form>
)}
</section>
);
}

View File

@@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { api } from "../api";
import Icon from "../components/Icon";
/**
* Ziel eines gescannten QR-Codes (/i/<uid>): löst die UID auf und öffnet den
* zugehörigen Artikel mit dem Einzelstück im Blick.
*/
export default function ItemResolve() {
const { uid } = useParams();
const navigate = useNavigate();
const [error, setError] = useState(null);
useEffect(() => {
let ok = true;
api.itemByUid(uid)
.then((item) => {
if (ok) navigate(`/products/${item.product_id}?item=${encodeURIComponent(item.uid)}`,
{ replace: true });
})
.catch((err) => { if (ok) setError(err.message); });
return () => { ok = false; };
}, [uid, navigate]);
return (
<div>
<div className="page-head">
<div>
<h1>Einzelstück {uid}</h1>
<div className="sub">{error ? error : "Wird geöffnet…"}</div>
</div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
</div>
);
}

View File

@@ -12,6 +12,7 @@ import { useSettings } from "../settings";
import { asTree } from "../categoryTree";
import { DynamicFields, FIELD_TYPES } from "../fields";
import ObjektBestand from "../components/ObjektBestand";
import Einzelstuecke from "../components/Einzelstuecke";
import {
daysUntil, expiryRowClass, fmt, fromMonthInput, gebinde, isExpired, relativeExpiry,
toMonthInput, unitShort,
@@ -20,7 +21,7 @@ import {
const EMPTY = {
barcode: "", name: "", brand: "", image_url: "",
unit_id: "", package_size: "", package_label: "", date_precision: "day", min_stock: "",
group_id: "", category_id: "", shop_id: "", product_url: "",
group_id: "", category_id: "", shop_id: "", product_url: "", individual: false,
};
// (Gebinde kommen jetzt aus der Verwaltung, siehe api.listPackageTypes)
@@ -384,6 +385,7 @@ export default function ProductForm() {
category_id: p.category_id ?? "",
shop_id: p.shop_id ?? "",
product_url: p.product_url || "",
individual: Boolean(p.individual),
});
setFieldValues(p.field_values || {});
setModus(p.tracking === "object" ? "object" : "food");
@@ -440,10 +442,11 @@ export default function ProductForm() {
Object.entries(fieldValues).forEach(([k, v]) => {
if (erlaubt.has(String(k))) fv[k] = v;
});
return { ...base, field_values: fv };
return { ...base, individual: form.individual, field_values: fv };
}
return {
...base,
individual: false,
unit_id: form.unit_id === "" ? null : Number(form.unit_id),
package_size: form.package_size === "" ? null : Number(form.package_size),
package_label: form.package_label.trim() || null,
@@ -740,13 +743,27 @@ export default function ProductForm() {
{isObject && (<>
<div className="row">
<label className="grow">
Gekauft bei
<select value={form.shop_id} onChange={(e) => set("shop_id", e.target.value)}
disabled={readOnly}>
<option value=""> unbekannt / mehrere </option>
{shops.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
Verwaltung
<div className="segmented">
<button type="button" className={!form.individual ? "active" : ""}
onClick={() => set("individual", false)} disabled={readOnly}>Menge je Lagerort</button>
<button type="button" className={form.individual ? "active" : ""}
onClick={() => set("individual", true)} disabled={readOnly}>Einzelstücke</button>
</div>
</label>
<div className="grow" />
</div>
<div className="row">
{!form.individual && (
<label className="grow">
Gekauft bei
<select value={form.shop_id} onChange={(e) => set("shop_id", e.target.value)}
disabled={readOnly}>
<option value=""> unbekannt / mehrere </option>
{shops.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</label>
)}
<label className="grow">
Produktlink
<div className="field-inline">
@@ -831,7 +848,7 @@ export default function ProductForm() {
</form>
{!isNew && (isObject ? (
product && (
product && !form.individual && (
<ObjektBestand
product={product}
lots={lots}
@@ -860,6 +877,18 @@ export default function ProductForm() {
))}
</div>
{/* Einzelstücke über die volle Breite die Tabelle hat viele Spalten. */}
{!isNew && product && isObject && form.individual && (
<Einzelstuecke
product={product}
locations={locations}
shops={shops}
isAdmin={isAdmin}
onChanged={async () => { setProduct(await api.getProduct(id)); }}
onError={setError}
/>
)}
{/* Unterhalb der beiden Spalten: die Codes braucht man selten, sie sollen
Formular und Chargen nicht auseinanderschieben. */}
{!isNew && product && (