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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user