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:
273
web/src/components/Einzelstuecke.jsx
Normal file
273
web/src/components/Einzelstuecke.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user