Web: Kaufpreis + Beleg schon beim Anlegen; Kaufdatum/Shop-Vorschlag

- Anlege-Maske: Kaufpreis (+Währung) und Beleg-Upload. Beim Datei-Auswählen wird
  der Beleg analysiert (ohne Speichern) und Kaufdatum/Garantie/Preis/Shop
  vorbefuellt; ein erkannter, noch nicht hinterlegter Shop wird beim Anlegen
  erstellt. Der Beleg haengt an das erste angelegte Stueck.
- Beleg-Vorschlag (bestehende Stuecke) zeigt/uebernimmt jetzt auch Kaufdatum und
  Shop (bekannter Shop zuweisen, sonst anlegen).
- API analyzeItemDocument.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-27 09:36:25 +02:00
parent bbf10b36c6
commit 4e7512b4bc
2 changed files with 121 additions and 17 deletions

View File

@@ -164,6 +164,12 @@ export const api = {
deleteItem: (id) => request(`/items/${id}`, { method: "DELETE" }), deleteItem: (id) => request(`/items/${id}`, { method: "DELETE" }),
// Belege je Einzelstück (Rechnung/Garantieschein). Der Upload liefert einen // Belege je Einzelstück (Rechnung/Garantieschein). Der Upload liefert einen
// vorgeschlagenen "Garantie bis"-Wert zurück (nur aus PDFs). // vorgeschlagenen "Garantie bis"-Wert zurück (nur aus PDFs).
// Beleg nur analysieren (nichts speichern) zum Vorbefüllen beim Anlegen.
analyzeItemDocument: (file) => {
const fd = new FormData();
fd.append("file", file);
return request("/items/analyze-document", { method: "POST", formData: fd });
},
uploadItemDocument: (itemId, file) => { uploadItemDocument: (itemId, file) => {
const fd = new FormData(); const fd = new FormData();
fd.append("file", file); fd.append("file", file);

View File

@@ -25,6 +25,7 @@ function QrImg({ text, size = 60 }) {
const EMPTY_ADD = { const EMPTY_ADD = {
count: 1, location_id: "", shop_id: "", acquired_on: "", warranty_until: "", note: "", count: 1, location_id: "", shop_id: "", acquired_on: "", warranty_until: "", note: "",
price: "", currency: "CHF",
}; };
/** /**
@@ -39,6 +40,9 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
const [remove, setRemove] = useState(null); // { item, reason, note } | null const [remove, setRemove] = useState(null); // { item, reason, note } | null
const [showAdd, setShowAdd] = useState(false); // Anlege-Maske ein-/ausklappen const [showAdd, setShowAdd] = useState(false); // Anlege-Maske ein-/ausklappen
const [suggestion, setSuggestion] = useState(null); // { itemId, date } aus PDF-Analyse const [suggestion, setSuggestion] = useState(null); // { itemId, date } aus PDF-Analyse
const [docFile, setDocFile] = useState(null); // Beleg, der beim Anlegen mitkommt
const [newShopName, setNewShopName] = useState(null); // erkannter, noch nicht hinterlegter Shop
const [addInfo, setAddInfo] = useState(null); // Hinweis nach Beleg-Analyse
const origin = window.location.origin; const origin = window.location.origin;
async function load() { async function load() {
@@ -53,23 +57,91 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
if (onChanged) await onChanged(); if (onChanged) await onChanged();
} }
// Kaufpreis-Eingabe (Hauptwährungseinheit) → Rappen/Cent.
function centsFrom(str) {
const roh = String(str).trim().replace(",", ".");
if (roh === "") return null;
const v = Math.round(parseFloat(roh) * 100);
return Number.isNaN(v) ? null : v;
}
// Beleg beim Anlegen: analysieren und Kaufdatum/Garantie/Preis/Shop vorbefüllen.
async function pickDoc(file) {
setDocFile(file);
setAddInfo(null);
setNewShopName(null);
try {
const s = await api.analyzeItemDocument(file);
const patch = {};
if (s.suggested_acquired_on) patch.acquired_on = s.suggested_acquired_on;
if (s.suggested_warranty_until) patch.warranty_until = s.suggested_warranty_until;
if (s.suggested_price_cents != null) { patch.price = (s.suggested_price_cents / 100).toFixed(2); patch.currency = "CHF"; }
if (s.suggested_shop_id != null) patch.shop_id = String(s.suggested_shop_id);
setAddForm((f) => ({ ...f, ...patch }));
if (s.suggested_shop_id == null && s.suggested_shop_name) setNewShopName(s.suggested_shop_name);
const teile = [];
if (s.suggested_acquired_on) teile.push("Kaufdatum");
if (s.suggested_warranty_until) teile.push("Garantie");
if (s.suggested_price_cents != null) teile.push("Preis");
if (s.suggested_shop_id != null) teile.push("Shop");
setAddInfo(teile.length ? `Aus dem Beleg übernommen: ${teile.join(", ")}.` : "Beleg erkannt keine Automatik-Treffer.");
} catch (err) { onError(err.message); }
}
async function add(e) { async function add(e) {
e.preventDefault(); e.preventDefault();
try { try {
await api.createItems(product.id, { // Erkannter, aber noch nicht hinterlegter Shop: beim Anlegen erstellen.
let shopId = addForm.shop_id === "" ? null : Number(addForm.shop_id);
if (shopId == null && newShopName) {
const shop = await api.createShop({ name: newShopName });
shopId = shop.id;
}
const cents = centsFrom(addForm.price);
const created = await api.createItems(product.id, {
count: Number(addForm.count) || 1, count: Number(addForm.count) || 1,
location_id: addForm.location_id === "" ? null : Number(addForm.location_id), location_id: addForm.location_id === "" ? null : Number(addForm.location_id),
shop_id: addForm.shop_id === "" ? null : Number(addForm.shop_id), shop_id: shopId,
acquired_on: addForm.acquired_on || null, acquired_on: addForm.acquired_on || null,
warranty_until: addForm.warranty_until || null, warranty_until: addForm.warranty_until || null,
note: addForm.note || null, note: addForm.note || null,
price_cents: cents,
currency: cents == null ? null : addForm.currency,
}); });
setAddForm({ ...EMPTY_ADD, location_id: addForm.location_id, shop_id: addForm.shop_id }); // Beleg an das erste angelegte Stück hängen.
if (docFile && created && created.length) {
await api.uploadItemDocument(created[0].id, docFile);
}
setAddForm({ ...EMPTY_ADD, location_id: addForm.location_id, shop_id: addForm.shop_id, currency: addForm.currency });
setDocFile(null);
setNewShopName(null);
setAddInfo(null);
await nachAktion(); await nachAktion();
toast("Einzelstück(e) angelegt."); toast("Einzelstück(e) angelegt.");
} catch (err) { onError(err.message); } } catch (err) { onError(err.message); }
} }
const shopName = (id) => shops.find((s) => s.id === id)?.name || "?";
// Beleg-Vorschlag an ein bestehendes Stück übernehmen (Shop ggf. anlegen).
async function applySuggestion(it) {
try {
const body = {};
if (suggestion.date) body.warranty_until = suggestion.date;
if (suggestion.acquiredOn) body.acquired_on = suggestion.acquiredOn;
if (suggestion.priceCents != null) { body.price_cents = suggestion.priceCents; body.currency = it.currency || "CHF"; }
if (suggestion.shopId != null) {
body.shop_id = suggestion.shopId;
} else if (suggestion.shopName) {
const shop = await api.createShop({ name: suggestion.shopName });
body.shop_id = shop.id;
if (onChanged) await onChanged();
}
await patchItem(it, body);
setSuggestion(null);
} catch (err) { onError(err.message); }
}
// Kaufpreis in Hauptwährungseinheit eingeben, als Rappen/Cent speichern. // Kaufpreis in Hauptwährungseinheit eingeben, als Rappen/Cent speichern.
function savePrice(item, value) { function savePrice(item, value) {
const roh = value.trim().replace(",", "."); const roh = value.trim().replace(",", ".");
@@ -83,12 +155,17 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
try { try {
const res = await api.uploadItemDocument(item.id, file); const res = await api.uploadItemDocument(item.id, file);
await load(); await load();
if (res && (res.suggested_warranty_until || res.suggested_price_cents != null)) { const hatVorschlag = res && (res.suggested_warranty_until || res.suggested_price_cents != null
|| res.suggested_acquired_on || res.suggested_shop_id != null || res.suggested_shop_name);
if (hatVorschlag) {
setSuggestion({ setSuggestion({
itemId: item.id, itemId: item.id,
date: res.suggested_warranty_until || null, date: res.suggested_warranty_until || null,
priceCents: res.suggested_price_cents ?? null, priceCents: res.suggested_price_cents ?? null,
priceCandidates: res.suggested_price_candidates || [], priceCandidates: res.suggested_price_candidates || [],
acquiredOn: res.suggested_acquired_on || null,
shopId: res.suggested_shop_id ?? null,
shopName: res.suggested_shop_name || null,
}); });
} }
toast("Beleg hochgeladen."); toast("Beleg hochgeladen.");
@@ -265,13 +342,13 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadDoc(it, f); e.target.value = ""; }} /> onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadDoc(it, f); e.target.value = ""; }} />
)} )}
</label> </label>
{suggestion && suggestion.itemId === it.id {suggestion && suggestion.itemId === it.id && (
&& (suggestion.date || suggestion.priceCents != null) && (
<div className="alert ok" style={{ marginTop: "var(--sp-2)", flexWrap: "wrap" }}> <div className="alert ok" style={{ marginTop: "var(--sp-2)", flexWrap: "wrap" }}>
<Icon name="check" size={16} /> <Icon name="check" size={16} />
<span> <span>
Im Beleg erkannt Im Beleg erkannt
{suggestion.date ? `: Garantie bis ${suggestion.date}` : ""} {suggestion.acquiredOn ? `: gekauft ${suggestion.acquiredOn}` : ""}
{suggestion.date ? `${suggestion.acquiredOn ? "," : ":"} Garantie bis ${suggestion.date}` : ""}
</span> </span>
{suggestion.priceCents != null && ( {suggestion.priceCents != null && (
(suggestion.priceCandidates || []).length > 1 ? ( (suggestion.priceCandidates || []).length > 1 ? (
@@ -288,17 +365,14 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
<span>Kaufpreis {(suggestion.priceCents / 100).toFixed(2)} {it.currency || "CHF"}</span> <span>Kaufpreis {(suggestion.priceCents / 100).toFixed(2)} {it.currency || "CHF"}</span>
) )
)} )}
{suggestion.shopId != null && (
<span>Shop: {shopName(suggestion.shopId)}</span>
)}
{suggestion.shopId == null && suggestion.shopName && (
<span>Shop anlegen: <strong>{suggestion.shopName}</strong></span>
)}
<button type="button" className="btn sm" style={{ marginLeft: "auto" }} <button type="button" className="btn sm" style={{ marginLeft: "auto" }}
onClick={() => { onClick={() => applySuggestion(it)}>
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 Übernehmen
</button> </button>
<button type="button" className="btn sm ghost" onClick={() => setSuggestion(null)}>Verwerfen</button> <button type="button" className="btn sm ghost" onClick={() => setSuggestion(null)}>Verwerfen</button>
@@ -382,9 +456,33 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
onChange={(e) => setAddForm({ ...addForm, note: e.target.value })} /> onChange={(e) => setAddForm({ ...addForm, note: e.target.value })} />
</label> </label>
</div> </div>
<div className="row">
<label style={{ width: 200 }}>Kaufpreis
<div className="field-inline">
<input type="number" min="0" step="0.01" placeholder="0.00" value={addForm.price}
onChange={(e) => setAddForm({ ...addForm, price: e.target.value })} />
<select value={addForm.currency} style={{ marginTop: 0 }}
onChange={(e) => setAddForm({ ...addForm, currency: e.target.value })}>
<option value="CHF">CHF</option>
<option value="EUR">EUR</option>
</select>
</div>
</label>
<label className="grow">Beleg (Rechnung/Garantieschein)
<input type="file" accept="application/pdf,image/*"
onChange={(e) => { const f = e.target.files?.[0]; if (f) pickDoc(f); }} />
</label>
</div>
{addInfo && <p className="muted small mt-0"><Icon name="check" size={14} /> {addInfo}</p>}
{newShopName && (
<p className="muted small mt-0">
Neuer Shop <strong>{newShopName}</strong> wird beim Anlegen erstellt oder oben einen bestehenden wählen.
</p>
)}
<p className="muted small"> <p className="muted small">
Gemeinsame Startwerte für alle neuen Stücke danach je Stück änderbar 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. (z.B. dasselbe Modell 2024 und 2025). Jedes bekommt eine eigene UID + QR.
Ein Beleg wird an das erste angelegte Stück gehängt.
</p> </p>
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button> <button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
</form> </form>