diff --git a/web/src/api.js b/web/src/api.js index c04243f..53510cc 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -164,6 +164,12 @@ export const api = { deleteItem: (id) => request(`/items/${id}`, { method: "DELETE" }), // Belege je Einzelstück (Rechnung/Garantieschein). Der Upload liefert einen // 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) => { const fd = new FormData(); fd.append("file", file); diff --git a/web/src/components/Einzelstuecke.jsx b/web/src/components/Einzelstuecke.jsx index af013ef..f13edb7 100644 --- a/web/src/components/Einzelstuecke.jsx +++ b/web/src/components/Einzelstuecke.jsx @@ -25,6 +25,7 @@ function QrImg({ text, size = 60 }) { const EMPTY_ADD = { 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 [showAdd, setShowAdd] = useState(false); // Anlege-Maske ein-/ausklappen 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; async function load() { @@ -53,23 +57,91 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh 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) { e.preventDefault(); 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, 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, warranty_until: addForm.warranty_until || 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(); toast("Einzelstück(e) angelegt."); } 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. function savePrice(item, value) { const roh = value.trim().replace(",", "."); @@ -83,12 +155,17 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh try { const res = await api.uploadItemDocument(item.id, file); 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({ itemId: item.id, date: res.suggested_warranty_until || null, priceCents: res.suggested_price_cents ?? null, 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."); @@ -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 = ""; }} /> )} - {suggestion && suggestion.itemId === it.id - && (suggestion.date || suggestion.priceCents != null) && ( + {suggestion && suggestion.itemId === it.id && (
Im Beleg erkannt - {suggestion.date ? `: Garantie bis ${suggestion.date}` : ""} + {suggestion.acquiredOn ? `: gekauft ${suggestion.acquiredOn}` : ""} + {suggestion.date ? `${suggestion.acquiredOn ? "," : ":"} Garantie bis ${suggestion.date}` : ""} {suggestion.priceCents != null && ( (suggestion.priceCandidates || []).length > 1 ? ( @@ -288,17 +365,14 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh Kaufpreis {(suggestion.priceCents / 100).toFixed(2)} {it.currency || "CHF"} ) )} + {suggestion.shopId != null && ( + Shop: {shopName(suggestion.shopId)} + )} + {suggestion.shopId == null && suggestion.shopName && ( + Shop anlegen: {suggestion.shopName} + )} @@ -382,9 +456,33 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh onChange={(e) => setAddForm({ ...addForm, note: e.target.value })} />
+
+ + +
+ {addInfo &&

{addInfo}

} + {newShopName && ( +

+ Neuer Shop {newShopName} wird beim Anlegen erstellt – oder oben einen bestehenden wählen. +

+ )}

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. + Ein Beleg wird an das erste angelegte Stück gehängt.