Web: Kaufpreis + Belege an Einzelstücken

Je Einzelstück eine ausklappbare Detailzeile: Kaufpreis (Betrag + CHF/EUR) und
Belege (Rechnung/Garantieschein) hochladen, ansehen, löschen. Nach dem Upload
eines PDFs schlägt der Server „Garantie bis" und Kaufpreis vor – per Knopf
übernehmbar. Belege werden mit Anmeldung geladen (kein offener Zugriff).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-26 20:57:30 +02:00
parent ba65e48d47
commit b9709d29bf
2 changed files with 148 additions and 16 deletions

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { Fragment, useEffect, useState } from "react";
import QRCode from "qrcode";
import { api } from "../api";
import { api, authorizedObjectUrl } from "../api";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "./Icon";
@@ -37,6 +37,8 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
const [items, setItems] = useState([]);
const [addForm, setAddForm] = useState(EMPTY_ADD);
const [remove, setRemove] = useState(null); // { item, reason, note } | null
const [expanded, setExpanded] = useState(null); // Item-ID mit offenem Detail (Preis/Belege)
const [suggestion, setSuggestion] = useState(null); // { itemId, date } aus PDF-Analyse
const origin = window.location.origin;
async function load() {
@@ -68,6 +70,44 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
} 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);
@@ -144,7 +184,8 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
</thead>
<tbody>
{items.map((it) => (
<tr key={it.id}>
<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">
@@ -164,7 +205,8 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
</td>
<td data-label="Garantie bis">
{isAdmin ? (
<input type="date" defaultValue={it.warranty_until || ""} style={{ marginTop: 0 }}
<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>
@@ -184,20 +226,101 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
) : <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>
)}
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
<button className="btn-icon" title="Preis & Belege"
onClick={() => setExpanded(expanded === it.id ? null : it.id)}>
<Icon name={expanded === it.id ? "close" : "package"} size={16} />
</button>
{isAdmin && (
<>
<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>
{expanded === it.id && (
<tr>
<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>