Files
Vorrania/web/src/components/Einzelstuecke.jsx
Scarriffle e0e0323f99 Web: Einzelstücke – Preis/Belege immer sichtbar, Anlegen als Button
Die Anlege-Maske war ein grosses, dauerhaft sichtbares Formular. Jetzt klappt
sie hinter einen "Einzelstück anlegen"-Knopf. Dafuer werden Kaufpreis und Belege
je Stueck nicht mehr ueber einen Aufklapp-Knopf versteckt, sondern direkt unter
jeder Zeile angezeigt (leicht abgesetzte Detailzeile).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 21:14:51 +02:00

401 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Fragment, useEffect, useState } from "react";
import QRCode from "qrcode";
import { api, authorizedObjectUrl } 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 [showAdd, setShowAdd] = useState(false); // Anlege-Maske ein-/ausklappen
const [suggestion, setSuggestion] = useState(null); // { itemId, date } aus PDF-Analyse
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); }
}
// Kaufpreis in Hauptwährungseinheit eingeben, als Rappen/Cent speichern.
function savePrice(item, value) {
const roh = value.trim().replace(",", ".");
const cents = roh === "" ? null : Math.round(parseFloat(roh) * 100);
if (cents !== null && Number.isNaN(cents)) return;
if (cents === (item.price_cents ?? null)) return;
patchItem(item, { price_cents: cents, currency: cents == null ? null : (item.currency || "CHF") });
}
async function uploadDoc(item, file) {
try {
const res = await api.uploadItemDocument(item.id, file);
await load();
if (res && (res.suggested_warranty_until || res.suggested_price_cents != null)) {
setSuggestion({
itemId: item.id,
date: res.suggested_warranty_until || null,
priceCents: res.suggested_price_cents ?? null,
});
}
toast("Beleg hochgeladen.");
} catch (err) { onError(err.message); }
}
async function deleteDoc(item, doc) {
try {
await api.deleteItemDocument(item.id, doc.id);
await load();
} catch (err) { onError(err.message); }
}
async function viewDoc(item, doc) {
try {
const url = await authorizedObjectUrl(`/items/${item.id}/documents/${doc.id}`);
if (url) window.open(url, "_blank", "noopener");
} 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) => (
<Fragment key={it.id}>
<tr>
<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 key={it.warranty_until || "none"} 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>
<tr className="einzel-detailrow">
<td colSpan={8}>
<div style={{ display: "grid", gap: "var(--sp-4)",
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))" }}>
<label style={{ margin: 0 }}>Kaufpreis
<div className="field-inline">
<input key={it.price_cents ?? "none"} type="number" step="0.01" min="0"
style={{ maxWidth: 140 }} disabled={!isAdmin}
defaultValue={it.price_cents != null ? (it.price_cents / 100) : ""}
placeholder="0.00" onBlur={(e) => savePrice(it, e.target.value)} />
<select defaultValue={it.currency || "CHF"} disabled={!isAdmin}
onChange={(e) => patchItem(it, { currency: e.target.value })}>
<option value="CHF">CHF</option>
<option value="EUR">EUR</option>
</select>
</div>
</label>
<div>
<label style={{ margin: 0 }}>Belege (Rechnung/Garantieschein)
{isAdmin && (
<input type="file" accept="application/pdf,image/*"
onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadDoc(it, f); e.target.value = ""; }} />
)}
</label>
{suggestion && suggestion.itemId === it.id
&& (suggestion.date || suggestion.priceCents != null) && (
<div className="alert ok" style={{ marginTop: "var(--sp-2)" }}>
<Icon name="check" size={16} />
<span>
Im Beleg erkannt:
{suggestion.date ? ` Garantie bis ${suggestion.date}` : ""}
{suggestion.date && suggestion.priceCents != null ? "," : ""}
{suggestion.priceCents != null ? ` Kaufpreis ${(suggestion.priceCents / 100).toFixed(2)}` : ""}.
</span>
<button type="button" className="btn sm" style={{ marginLeft: "auto" }}
onClick={() => {
const body = {};
if (suggestion.date) body.warranty_until = suggestion.date;
if (suggestion.priceCents != null) {
body.price_cents = suggestion.priceCents;
body.currency = it.currency || "CHF";
}
patchItem(it, body);
setSuggestion(null);
}}>
Übernehmen
</button>
<button type="button" className="btn sm ghost" onClick={() => setSuggestion(null)}>Verwerfen</button>
</div>
)}
<ul className="clean-list" style={{ marginTop: "var(--sp-2)" }}>
{(it.documents || []).map((d) => (
<li key={d.id} className="cell-row">
<button type="button" className="btn sm ghost" onClick={() => viewDoc(it, d)}>
<Icon name="download" size={14} />{d.filename}
</button>
{isAdmin && (
<button className="btn-icon danger" style={{ marginLeft: "auto" }}
title="Beleg löschen" onClick={() => deleteDoc(it, d)}>
<Icon name="trash" size={15} />
</button>
)}
</li>
))}
{(it.documents || []).length === 0 && (
<li className="muted small">Noch keine Belege.</li>
)}
</ul>
</div>
</div>
</td>
</tr>
</Fragment>
))}
{items.length === 0 && <tr><td colSpan={8} className="empty">Noch keine Einzelstücke.</td></tr>}
</tbody>
</table>
</div>
{isAdmin && !showAdd && (
<button type="button" className="btn" style={{ marginTop: "var(--sp-3)" }}
onClick={() => setShowAdd(true)}>
<Icon name="plus" size={16} />Einzelstück anlegen
</button>
)}
{isAdmin && showAdd && (
<form onSubmit={add} className="card-sub" style={{ marginTop: "var(--sp-3)" }}>
<div className="card-head"><Icon name="plus" /><h3>Einzelstücke anlegen</h3>
<button type="button" className="btn-icon" style={{ marginLeft: "auto" }}
title="Schließen" onClick={() => setShowAdd(false)}>
<Icon name="close" size={16} />
</button>
</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>
);
}