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:
@@ -162,6 +162,15 @@ export const api = {
|
|||||||
updateItem: (id, body) => request(`/items/${id}`, { method: "PATCH", body }),
|
updateItem: (id, body) => request(`/items/${id}`, { method: "PATCH", body }),
|
||||||
removeItem: (id, body) => request(`/items/${id}/remove`, { method: "POST", body }),
|
removeItem: (id, body) => request(`/items/${id}/remove`, { method: "POST", body }),
|
||||||
deleteItem: (id) => request(`/items/${id}`, { method: "DELETE" }),
|
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).
|
||||||
|
uploadItemDocument: (itemId, file) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
return request(`/items/${itemId}/documents`, { method: "POST", formData: fd });
|
||||||
|
},
|
||||||
|
deleteItemDocument: (itemId, docId) =>
|
||||||
|
request(`/items/${itemId}/documents/${docId}`, { method: "DELETE" }),
|
||||||
|
|
||||||
// Bestand
|
// Bestand
|
||||||
checkIn: (body) => request("/stock/checkin", { method: "POST", body }),
|
checkIn: (body) => request("/stock/checkin", { method: "POST", body }),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { Fragment, useEffect, useState } from "react";
|
||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
import { api } from "../api";
|
import { api, authorizedObjectUrl } from "../api";
|
||||||
import { useConfirm } from "../confirm";
|
import { useConfirm } from "../confirm";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
@@ -37,6 +37,8 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
|||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [addForm, setAddForm] = useState(EMPTY_ADD);
|
const [addForm, setAddForm] = useState(EMPTY_ADD);
|
||||||
const [remove, setRemove] = useState(null); // { item, reason, note } | null
|
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;
|
const origin = window.location.origin;
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -68,6 +70,44 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
|||||||
} catch (err) { onError(err.message); }
|
} 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) {
|
async function patchItem(item, body) {
|
||||||
try {
|
try {
|
||||||
await api.updateItem(item.id, body);
|
await api.updateItem(item.id, body);
|
||||||
@@ -144,7 +184,8 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((it) => (
|
{items.map((it) => (
|
||||||
<tr key={it.id}>
|
<Fragment key={it.id}>
|
||||||
|
<tr>
|
||||||
<td><QrImg text={`${origin}/i/${it.uid}`} size={56} /></td>
|
<td><QrImg text={`${origin}/i/${it.uid}`} size={56} /></td>
|
||||||
<td data-label="UID" className="strong nowrap">{it.uid}</td>
|
<td data-label="UID" className="strong nowrap">{it.uid}</td>
|
||||||
<td data-label="Lagerort">
|
<td data-label="Lagerort">
|
||||||
@@ -164,7 +205,8 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
|||||||
</td>
|
</td>
|
||||||
<td data-label="Garantie bis">
|
<td data-label="Garantie bis">
|
||||||
{isAdmin ? (
|
{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 }); }} />
|
onBlur={(e) => { if ((e.target.value || "") !== (it.warranty_until || "")) patchItem(it, { warranty_until: e.target.value || null }); }} />
|
||||||
) : <span>{it.warranty_until || "–"}</span>}
|
) : <span>{it.warranty_until || "–"}</span>}
|
||||||
</td>
|
</td>
|
||||||
@@ -184,20 +226,101 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
|||||||
) : <span>{it.note || "–"}</span>}
|
) : <span>{it.note || "–"}</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="num">
|
<td className="num">
|
||||||
{isAdmin && (
|
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
|
||||||
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
|
<button className="btn-icon" title="Preis & Belege"
|
||||||
<button className="btn-icon" title="Mit Grund entfernen"
|
onClick={() => setExpanded(expanded === it.id ? null : it.id)}>
|
||||||
onClick={() => setRemove({ item: it, reason: "broken", note: "" })}>
|
<Icon name={expanded === it.id ? "close" : "package"} size={16} />
|
||||||
<Icon name="checkout" size={16} />
|
</button>
|
||||||
</button>
|
{isAdmin && (
|
||||||
<button className="btn-icon danger" title="Löschen (Korrektur)"
|
<>
|
||||||
onClick={() => loeschen(it)}>
|
<button className="btn-icon" title="Mit Grund entfernen"
|
||||||
<Icon name="trash" size={16} />
|
onClick={() => setRemove({ item: it, reason: "broken", note: "" })}>
|
||||||
</button>
|
<Icon name="checkout" size={16} />
|
||||||
</div>
|
</button>
|
||||||
)}
|
<button className="btn-icon danger" title="Löschen (Korrektur)"
|
||||||
|
onClick={() => loeschen(it)}>
|
||||||
|
<Icon name="trash" size={16} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>}
|
{items.length === 0 && <tr><td colSpan={8} className="empty">Noch keine Einzelstücke.</td></tr>}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user