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