Compare commits
3 Commits
729dfe766b
...
a38c0651c5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a38c0651c5 | ||
|
|
4e7512b4bc | ||
|
|
bbf10b36c6 |
@@ -24,6 +24,7 @@ from ..models import (
|
|||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
|
DocumentSuggestions,
|
||||||
ItemCreate,
|
ItemCreate,
|
||||||
ItemDocumentUploadOut,
|
ItemDocumentUploadOut,
|
||||||
ItemOut,
|
ItemOut,
|
||||||
@@ -33,8 +34,10 @@ from ..schemas import (
|
|||||||
from ..services.items import generate_uid, item_to_out
|
from ..services.items import generate_uid, item_to_out
|
||||||
from ..services.warranty import (
|
from ..services.warranty import (
|
||||||
extract_pdf_text,
|
extract_pdf_text,
|
||||||
|
guess_acquired_on,
|
||||||
guess_price_candidates,
|
guess_price_candidates,
|
||||||
guess_price_cents,
|
guess_price_cents,
|
||||||
|
guess_shop,
|
||||||
guess_warranty_until,
|
guess_warranty_until,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -208,20 +211,8 @@ def _safe_filename(name: str | None) -> str:
|
|||||||
return cleaned[:255] or "beleg"
|
return cleaned[:255] or "beleg"
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
def _read_document(file: UploadFile, data: bytes) -> str:
|
||||||
"/items/{item_id}/documents",
|
"""Validiert Größe/Typ eines hochgeladenen Belegs und gibt den Content-Type."""
|
||||||
response_model=ItemDocumentUploadOut,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
)
|
|
||||||
async def upload_item_document(
|
|
||||||
item_id: int,
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
_: User = Depends(require_admin),
|
|
||||||
) -> ItemDocumentUploadOut:
|
|
||||||
"""Beleg (PDF oder Bild) hochladen. Bei PDF wird ein Garantieende vorgeschlagen."""
|
|
||||||
item = _item_or_404(db, item_id)
|
|
||||||
data = await file.read()
|
|
||||||
if not data:
|
if not data:
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Leere Datei")
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Leere Datei")
|
||||||
if len(data) > DOC_MAX_BYTES:
|
if len(data) > DOC_MAX_BYTES:
|
||||||
@@ -234,6 +225,43 @@ async def upload_item_document(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Nur PDF oder Bild erlaubt."
|
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Nur PDF oder Bild erlaubt."
|
||||||
)
|
)
|
||||||
|
return content_type
|
||||||
|
|
||||||
|
|
||||||
|
def _doc_suggestions(
|
||||||
|
db: Session, data: bytes, content_type: str, item_acquired_on=None
|
||||||
|
) -> DocumentSuggestions:
|
||||||
|
"""Garantie, Preis, Kaufdatum und Shop aus einem PDF schätzen (Bilder: leer)."""
|
||||||
|
if content_type != "application/pdf":
|
||||||
|
return DocumentSuggestions()
|
||||||
|
text = extract_pdf_text(data)
|
||||||
|
shop_id, shop_name = guess_shop(text, [(s.id, s.name) for s in db.query(Shop).all()])
|
||||||
|
return DocumentSuggestions(
|
||||||
|
suggested_warranty_until=guess_warranty_until(text, acquired_on=item_acquired_on),
|
||||||
|
suggested_price_cents=guess_price_cents(text),
|
||||||
|
suggested_price_candidates=guess_price_candidates(text),
|
||||||
|
suggested_acquired_on=guess_acquired_on(text),
|
||||||
|
suggested_shop_id=shop_id,
|
||||||
|
suggested_shop_name=shop_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/items/{item_id}/documents",
|
||||||
|
response_model=ItemDocumentUploadOut,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def upload_item_document(
|
||||||
|
item_id: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> ItemDocumentUploadOut:
|
||||||
|
"""Beleg (PDF oder Bild) hochladen. Bei PDF werden Garantie, Preis, Kaufdatum
|
||||||
|
und Shop vorgeschlagen."""
|
||||||
|
item = _item_or_404(db, item_id)
|
||||||
|
data = await file.read()
|
||||||
|
content_type = _read_document(file, data)
|
||||||
|
|
||||||
doc = ItemDocument(
|
doc = ItemDocument(
|
||||||
item_id=item.id,
|
item_id=item.id,
|
||||||
@@ -245,26 +273,28 @@ async def upload_item_document(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(doc)
|
db.refresh(doc)
|
||||||
|
|
||||||
# Garantieende und Preis nur aus PDFs schätzen (Bilder haben keine Textebene).
|
vorschlag = _doc_suggestions(db, data, content_type, item.acquired_on)
|
||||||
warranty = price = None
|
|
||||||
candidates: list[int] = []
|
|
||||||
if content_type == "application/pdf":
|
|
||||||
text = extract_pdf_text(data)
|
|
||||||
warranty = guess_warranty_until(text, acquired_on=item.acquired_on)
|
|
||||||
price = guess_price_cents(text)
|
|
||||||
candidates = guess_price_candidates(text)
|
|
||||||
|
|
||||||
return ItemDocumentUploadOut(
|
return ItemDocumentUploadOut(
|
||||||
id=doc.id,
|
id=doc.id,
|
||||||
filename=doc.filename,
|
filename=doc.filename,
|
||||||
content_type=doc.content_type,
|
content_type=doc.content_type,
|
||||||
uploaded_at=doc.uploaded_at,
|
uploaded_at=doc.uploaded_at,
|
||||||
suggested_warranty_until=warranty,
|
**vorschlag.model_dump(),
|
||||||
suggested_price_cents=price,
|
|
||||||
suggested_price_candidates=candidates,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items/analyze-document", response_model=DocumentSuggestions)
|
||||||
|
async def analyze_item_document(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> DocumentSuggestions:
|
||||||
|
"""Beleg nur analysieren (nichts speichern) – zum Vorbefüllen beim Anlegen."""
|
||||||
|
data = await file.read()
|
||||||
|
content_type = _read_document(file, data)
|
||||||
|
return _doc_suggestions(db, data, content_type)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/items/{item_id}/documents/{doc_id}")
|
@router.get("/items/{item_id}/documents/{doc_id}")
|
||||||
def get_item_document(
|
def get_item_document(
|
||||||
item_id: int,
|
item_id: int,
|
||||||
|
|||||||
@@ -557,12 +557,20 @@ class ItemDocumentOut(BaseModel):
|
|||||||
uploaded_at: datetime
|
uploaded_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class ItemDocumentUploadOut(ItemDocumentOut):
|
class DocumentSuggestions(BaseModel):
|
||||||
"""Antwort nach dem Upload – mit aus dem PDF geschätztem Garantieende und Preis."""
|
"""Aus einem Beleg-PDF geschätzte Werte (ohne den Beleg zu speichern)."""
|
||||||
suggested_warranty_until: date | None = None
|
suggested_warranty_until: date | None = None
|
||||||
suggested_price_cents: int | None = None
|
suggested_price_cents: int | None = None
|
||||||
# Alle plausiblen Preise (bester zuerst) – zur Auswahl, falls es mehrere gibt.
|
# Alle plausiblen Preise (bester zuerst) – zur Auswahl, falls es mehrere gibt.
|
||||||
suggested_price_candidates: list[int] = []
|
suggested_price_candidates: list[int] = []
|
||||||
|
suggested_acquired_on: date | None = None
|
||||||
|
# Erkannter Shop: id = bereits hinterlegt; sonst name = Vorschlag zum Anlegen.
|
||||||
|
suggested_shop_id: int | None = None
|
||||||
|
suggested_shop_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ItemDocumentUploadOut(ItemDocumentOut, DocumentSuggestions):
|
||||||
|
"""Antwort nach dem Upload – Beleg-Metadaten plus die geschätzten Werte."""
|
||||||
|
|
||||||
|
|
||||||
class ItemOut(BaseModel):
|
class ItemOut(BaseModel):
|
||||||
|
|||||||
@@ -126,6 +126,57 @@ def guess_warranty_until(text: str, acquired_on: date | None = None) -> date | N
|
|||||||
return _find_date_near_keyword(text)
|
return _find_date_near_keyword(text)
|
||||||
|
|
||||||
|
|
||||||
|
def guess_acquired_on(text: str) -> date | None:
|
||||||
|
"""Kaufdatum aus dem Beleg (Datum nahe Rechnungs-/Kaufdatum-Stichwort)."""
|
||||||
|
return _find_purchase_date(text) if text else None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Shop / Bezugsquelle aus dem Beleg
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
_LEGAL_RE = re.compile(
|
||||||
|
r"\b(GmbH|AG|SA|Sàrl|S\.?à r\.?l\.?|Ltd|Inc|SE|KG|OHG|e\.?K\.?|AS|BV|S\.p\.A\.)\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _vendor_candidate(text: str) -> str | None:
|
||||||
|
"""Bester Rate-Name des Händlers – meist im Kopf des Belegs. Best effort."""
|
||||||
|
lines = [z.strip() for z in text.splitlines() if z.strip()]
|
||||||
|
for z in lines[:20]:
|
||||||
|
if _LEGAL_RE.search(z):
|
||||||
|
return z[:60]
|
||||||
|
for z in lines[:8]:
|
||||||
|
buchstaben = sum(c.isalpha() for c in z)
|
||||||
|
if 3 <= len(z) <= 40 and buchstaben >= 3 and not re.search(
|
||||||
|
r"rechnung|invoice|quittung|beleg|kassenbon|datum|receipt|order|bestell",
|
||||||
|
z, re.IGNORECASE,
|
||||||
|
):
|
||||||
|
return z[:60]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def guess_shop(text: str, shops: list[tuple[int, str]]) -> tuple[int | None, str | None]:
|
||||||
|
"""(shop_id, name).
|
||||||
|
|
||||||
|
Kommt der Name eines bereits hinterlegten Shops im Beleg vor, wird dieser
|
||||||
|
vorgeschlagen (längster Treffer gewinnt). Sonst ein Kandidatenname zum
|
||||||
|
Anlegen – oder (None, None), wenn nichts Brauchbares gefunden wird.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return (None, None)
|
||||||
|
low = text.lower()
|
||||||
|
treffer: tuple[int, str] | None = None
|
||||||
|
best_len = 0
|
||||||
|
for sid, name in shops:
|
||||||
|
n = name.strip().lower()
|
||||||
|
if len(n) >= 3 and n in low and len(n) > best_len:
|
||||||
|
treffer = (sid, name)
|
||||||
|
best_len = len(n)
|
||||||
|
if treffer is not None:
|
||||||
|
return treffer
|
||||||
|
return (None, _vendor_candidate(text))
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Kaufpreis aus dem Beleg schätzen
|
# Kaufpreis aus dem Beleg schätzen
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from app.services.warranty import (
|
from app.services.warranty import (
|
||||||
|
guess_acquired_on,
|
||||||
guess_price_candidates,
|
guess_price_candidates,
|
||||||
guess_price_cents,
|
guess_price_cents,
|
||||||
|
guess_shop,
|
||||||
guess_warranty_until,
|
guess_warranty_until,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,3 +88,25 @@ def test_preis_kandidaten_bester_zuerst_ohne_dubletten():
|
|||||||
assert kandidaten[0] == 5490 # bester Tipp (Summe) vorne
|
assert kandidaten[0] == 5490 # bester Tipp (Summe) vorne
|
||||||
assert set(kandidaten) == {5490, 4900, 590} # ohne Dubletten
|
assert set(kandidaten) == {5490, 4900, 590} # ohne Dubletten
|
||||||
assert guess_price_candidates("") == []
|
assert guess_price_candidates("") == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Kaufdatum & Shop ----
|
||||||
|
|
||||||
|
def test_kaufdatum_aus_beleg():
|
||||||
|
assert guess_acquired_on("Rechnungsdatum: 05.06.2024") == date(2024, 6, 5)
|
||||||
|
assert guess_acquired_on("nichts hier") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_shop_erkennt_bekannten_namen():
|
||||||
|
shops = [(1, "Digitec"), (2, "Galaxus")]
|
||||||
|
assert guess_shop("Rechnung von Digitec AG, Zürich", shops) == (1, "Digitec")
|
||||||
|
|
||||||
|
|
||||||
|
def test_shop_schlaegt_neuen_namen_vor():
|
||||||
|
sid, name = guess_shop("ACME Electronics GmbH\nRechnung\nDatum 01.01.2024", [(1, "Galaxus")])
|
||||||
|
assert sid is None
|
||||||
|
assert name == "ACME Electronics GmbH"
|
||||||
|
|
||||||
|
|
||||||
|
def test_shop_ohne_text_leer():
|
||||||
|
assert guess_shop("", [(1, "Digitec")]) == (None, None)
|
||||||
|
|||||||
@@ -207,6 +207,25 @@ actor APIClient {
|
|||||||
return try await send(request, as: ItemDocumentUpload.self)
|
return try await send(request, as: ItemDocumentUpload.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Beleg nur analysieren (nichts speichern) – zum Vorbefüllen beim Anlegen.
|
||||||
|
func analyzeItemDocument(data: Data, filename: String,
|
||||||
|
contentType: String) async throws -> DocumentSuggestions {
|
||||||
|
var request = try makeRequest("/items/analyze-document", method: "POST")
|
||||||
|
let boundary = "Boundary-\(UUID().uuidString)"
|
||||||
|
request.setValue("multipart/form-data; boundary=\(boundary)",
|
||||||
|
forHTTPHeaderField: "Content-Type")
|
||||||
|
let safeName = filename.replacingOccurrences(of: "\"", with: "")
|
||||||
|
var body = Data()
|
||||||
|
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||||
|
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(safeName)\"\r\n"
|
||||||
|
.data(using: .utf8)!)
|
||||||
|
body.append("Content-Type: \(contentType)\r\n\r\n".data(using: .utf8)!)
|
||||||
|
body.append(data)
|
||||||
|
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
||||||
|
request.httpBody = body
|
||||||
|
return try await send(request, as: DocumentSuggestions.self)
|
||||||
|
}
|
||||||
|
|
||||||
func itemDocumentData(itemId: Int, docId: Int) async throws -> Data {
|
func itemDocumentData(itemId: Int, docId: Int) async throws -> Data {
|
||||||
let request = try makeRequest("/items/\(itemId)/documents/\(docId)")
|
let request = try makeRequest("/items/\(itemId)/documents/\(docId)")
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ struct ItemEditView: View {
|
|||||||
@State private var suggWarranty: String?
|
@State private var suggWarranty: String?
|
||||||
@State private var suggPrice: Int?
|
@State private var suggPrice: Int?
|
||||||
@State private var suggPriceCandidates: [Int] = []
|
@State private var suggPriceCandidates: [Int] = []
|
||||||
|
@State private var suggAcquired: String?
|
||||||
|
@State private var suggShopId: Int?
|
||||||
|
@State private var suggShopName: String?
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var busyDoc = false
|
@State private var busyDoc = false
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@@ -161,9 +164,10 @@ struct ItemEditView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section("Belege (Rechnung/Garantieschein)") {
|
Section("Belege (Rechnung/Garantieschein)") {
|
||||||
if suggWarranty != nil || suggPrice != nil {
|
if hatVorschlag {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Im Beleg erkannt:").font(.caption).foregroundStyle(.secondary)
|
Text("Im Beleg erkannt:").font(.caption).foregroundStyle(.secondary)
|
||||||
|
if let a = suggAcquired { Text("Gekauft am \(a)") }
|
||||||
if let w = suggWarranty { Text("Garantie bis \(w)") }
|
if let w = suggWarranty { Text("Garantie bis \(w)") }
|
||||||
if suggPriceCandidates.count > 1 {
|
if suggPriceCandidates.count > 1 {
|
||||||
// Mehrere Beträge gefunden – Auswahl anbieten.
|
// Mehrere Beträge gefunden – Auswahl anbieten.
|
||||||
@@ -179,8 +183,13 @@ struct ItemEditView: View {
|
|||||||
} else if let p = suggPrice {
|
} else if let p = suggPrice {
|
||||||
Text("Kaufpreis \(ItemEditView.formatCents(p)) \(currency)")
|
Text("Kaufpreis \(ItemEditView.formatCents(p)) \(currency)")
|
||||||
}
|
}
|
||||||
|
if let sid = suggShopId {
|
||||||
|
Text("Shop: \(shops.first(where: { $0.id == sid })?.name ?? "bekannt")")
|
||||||
|
} else if let name = suggShopName {
|
||||||
|
Text("Shop anlegen: \(name)")
|
||||||
|
}
|
||||||
HStack {
|
HStack {
|
||||||
Button("Übernehmen") { applySuggestion() }
|
Button("Übernehmen") { Task { await applySuggestion() } }
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Verwerfen") { verwerfeVorschlag() }
|
Button("Verwerfen") { verwerfeVorschlag() }
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -301,9 +310,13 @@ struct ItemEditView: View {
|
|||||||
do {
|
do {
|
||||||
let res = try await APIClient.shared.uploadItemDocument(
|
let res = try await APIClient.shared.uploadItemDocument(
|
||||||
itemId: item.id, data: data, filename: filename, contentType: contentType)
|
itemId: item.id, data: data, filename: filename, contentType: contentType)
|
||||||
suggWarranty = res.suggestedWarrantyUntil
|
let s = res.suggestions
|
||||||
suggPrice = res.suggestedPriceCents
|
suggWarranty = s.suggestedWarrantyUntil
|
||||||
suggPriceCandidates = res.suggestedPriceCandidates
|
suggPrice = s.suggestedPriceCents
|
||||||
|
suggPriceCandidates = s.suggestedPriceCandidates
|
||||||
|
suggAcquired = s.suggestedAcquiredOn
|
||||||
|
suggShopId = s.suggestedShopId
|
||||||
|
suggShopName = s.suggestedShopName
|
||||||
await reloadDocuments()
|
await reloadDocuments()
|
||||||
} catch { self.error = error.localizedDescription }
|
} catch { self.error = error.localizedDescription }
|
||||||
}
|
}
|
||||||
@@ -331,9 +344,23 @@ struct ItemEditView: View {
|
|||||||
} catch { self.error = error.localizedDescription }
|
} catch { self.error = error.localizedDescription }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applySuggestion() {
|
private var hatVorschlag: Bool {
|
||||||
|
suggWarranty != nil || suggPrice != nil || suggAcquired != nil
|
||||||
|
|| suggShopId != nil || suggShopName != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applySuggestion() async {
|
||||||
|
if let a = suggAcquired, let d = stringToDate(a) { acquired = d; hasAcquired = true }
|
||||||
if let w = suggWarranty, let d = stringToDate(w) { warranty = d; hasWarranty = true }
|
if let w = suggWarranty, let d = stringToDate(w) { warranty = d; hasWarranty = true }
|
||||||
if let p = suggPrice { priceText = ItemEditView.formatCents(p) }
|
if let p = suggPrice { priceText = ItemEditView.formatCents(p) }
|
||||||
|
if let sid = suggShopId {
|
||||||
|
shopId = sid
|
||||||
|
} else if let name = suggShopName,
|
||||||
|
let shop = try? await APIClient.shared.createShop(
|
||||||
|
NewShopRequest(name: name, website: nil)) {
|
||||||
|
if !shops.contains(where: { $0.id == shop.id }) { shops.append(shop) }
|
||||||
|
shopId = shop.id
|
||||||
|
}
|
||||||
verwerfeVorschlag()
|
verwerfeVorschlag()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,6 +368,9 @@ struct ItemEditView: View {
|
|||||||
suggWarranty = nil
|
suggWarranty = nil
|
||||||
suggPrice = nil
|
suggPrice = nil
|
||||||
suggPriceCandidates = []
|
suggPriceCandidates = []
|
||||||
|
suggAcquired = nil
|
||||||
|
suggShopId = nil
|
||||||
|
suggShopName = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Eingabe in Hauptwährungseinheit → Rappen/Cent.
|
/// Eingabe in Hauptwährungseinheit → Rappen/Cent.
|
||||||
@@ -383,6 +413,15 @@ struct ItemAddSheet: View {
|
|||||||
@State private var hasWarranty = false
|
@State private var hasWarranty = false
|
||||||
@State private var warranty = Date()
|
@State private var warranty = Date()
|
||||||
@State private var note = ""
|
@State private var note = ""
|
||||||
|
@State private var priceText = ""
|
||||||
|
@State private var currency = "CHF"
|
||||||
|
@State private var docData: Data?
|
||||||
|
@State private var docName = ""
|
||||||
|
@State private var docType = ""
|
||||||
|
@State private var newShopName: String?
|
||||||
|
@State private var addInfo: String?
|
||||||
|
@State private var showFileImporter = false
|
||||||
|
@State private var pickerItem: PhotosPickerItem?
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
|
||||||
@@ -406,6 +445,37 @@ struct ItemAddSheet: View {
|
|||||||
} footer: {
|
} footer: {
|
||||||
Text("Gemeinsame Startwerte – danach je Stück änderbar. Jedes Stück bekommt eine eigene UID + QR.")
|
Text("Gemeinsame Startwerte – danach je Stück änderbar. Jedes Stück bekommt eine eigene UID + QR.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section("Kaufpreis") {
|
||||||
|
HStack {
|
||||||
|
TextField("0.00", text: $priceText).keyboardType(.decimalPad)
|
||||||
|
Picker("", selection: $currency) {
|
||||||
|
Text("CHF").tag("CHF")
|
||||||
|
Text("EUR").tag("EUR")
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented).frame(width: 130)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
if docData != nil { Text(docName).font(.callout) }
|
||||||
|
PhotosPicker(selection: $pickerItem, matching: .images) {
|
||||||
|
Label("Bild wählen", systemImage: "photo")
|
||||||
|
}
|
||||||
|
Button { showFileImporter = true } label: {
|
||||||
|
Label("PDF wählen", systemImage: "doc.badge.plus")
|
||||||
|
}
|
||||||
|
if let addInfo { Text(addInfo).font(.caption).foregroundStyle(.secondary) }
|
||||||
|
if let newShopName {
|
||||||
|
Text("Neuer Shop „\(newShopName)“ wird beim Anlegen erstellt.")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Beleg (Rechnung/Garantieschein)")
|
||||||
|
} footer: {
|
||||||
|
Text("Der Beleg wird an das erste angelegte Stück gehängt; aus einem PDF werden Kaufdatum, Garantie, Preis und Shop vorgeschlagen.")
|
||||||
|
}
|
||||||
|
|
||||||
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
|
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
|
||||||
Section {
|
Section {
|
||||||
Button(busy ? "Anlegen…" : "Anlegen") { Task { await create() } }.disabled(busy)
|
Button(busy ? "Anlegen…" : "Anlegen") { Task { await create() } }.disabled(busy)
|
||||||
@@ -414,20 +484,83 @@ struct ItemAddSheet: View {
|
|||||||
.navigationTitle("Einzelstücke anlegen")
|
.navigationTitle("Einzelstücke anlegen")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
|
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
|
||||||
|
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.pdf]) { result in
|
||||||
|
if case .success(let url) = result {
|
||||||
|
Task {
|
||||||
|
let scoped = url.startAccessingSecurityScopedResource()
|
||||||
|
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
||||||
|
if let data = try? Data(contentsOf: url) {
|
||||||
|
await analyze(data, name: url.lastPathComponent, type: "application/pdf")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: pickerItem) { neu in
|
||||||
|
guard let neu else { return }
|
||||||
|
Task {
|
||||||
|
if let data = try? await neu.loadTransferable(type: Data.self) {
|
||||||
|
await analyze(data, name: "foto.jpg", type: "image/jpeg")
|
||||||
|
}
|
||||||
|
pickerItem = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
locations = (try? await APIClient.shared.locations()) ?? []
|
locations = (try? await APIClient.shared.locations()) ?? []
|
||||||
shops = (try? await APIClient.shared.shops()) ?? []
|
shops = (try? await APIClient.shared.shops()) ?? []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Beleg beim Anlegen analysieren und Felder vorbefüllen (nur bei PDF liefert
|
||||||
|
/// der Server Treffer).
|
||||||
|
private func analyze(_ data: Data, name: String, type: String) async {
|
||||||
|
docData = data
|
||||||
|
docName = name
|
||||||
|
docType = type
|
||||||
|
addInfo = nil
|
||||||
|
newShopName = nil
|
||||||
|
guard let s = try? await APIClient.shared.analyzeItemDocument(
|
||||||
|
data: data, filename: name, contentType: type) else { return }
|
||||||
|
var teile: [String] = []
|
||||||
|
if let a = s.suggestedAcquiredOn, let d = stringToDate(a) {
|
||||||
|
acquired = d; hasAcquired = true; teile.append("Kaufdatum")
|
||||||
|
}
|
||||||
|
if let w = s.suggestedWarrantyUntil, let d = stringToDate(w) {
|
||||||
|
warranty = d; hasWarranty = true; teile.append("Garantie")
|
||||||
|
}
|
||||||
|
if let p = s.suggestedPriceCents {
|
||||||
|
priceText = ItemEditView.formatCents(p); teile.append("Preis")
|
||||||
|
}
|
||||||
|
if let sid = s.suggestedShopId {
|
||||||
|
shopId = sid; teile.append("Shop")
|
||||||
|
} else if let sn = s.suggestedShopName {
|
||||||
|
newShopName = sn
|
||||||
|
}
|
||||||
|
addInfo = teile.isEmpty ? "Beleg erkannt – keine Automatik-Treffer."
|
||||||
|
: "Übernommen: \(teile.joined(separator: ", "))."
|
||||||
|
}
|
||||||
|
|
||||||
private func create() async {
|
private func create() async {
|
||||||
busy = true; defer { busy = false }; error = nil
|
busy = true; defer { busy = false }; error = nil
|
||||||
do {
|
do {
|
||||||
_ = try await APIClient.shared.createItems(productId: productId, ItemCreateRequest(
|
var sid = shopId
|
||||||
count: count, locationId: locationId, shopId: shopId,
|
if sid == nil, let name = newShopName,
|
||||||
|
let shop = try? await APIClient.shared.createShop(
|
||||||
|
NewShopRequest(name: name, website: nil)) {
|
||||||
|
sid = shop.id
|
||||||
|
}
|
||||||
|
let cents = ItemEditView.parseCents(priceText)
|
||||||
|
let created = try await APIClient.shared.createItems(productId: productId, ItemCreateRequest(
|
||||||
|
count: count, locationId: locationId, shopId: sid,
|
||||||
acquiredOn: hasAcquired ? dateToString(acquired) : nil,
|
acquiredOn: hasAcquired ? dateToString(acquired) : nil,
|
||||||
warrantyUntil: hasWarranty ? dateToString(warranty) : nil,
|
warrantyUntil: hasWarranty ? dateToString(warranty) : nil,
|
||||||
note: note.isEmpty ? nil : note))
|
note: note.isEmpty ? nil : note,
|
||||||
|
priceCents: cents,
|
||||||
|
currency: cents == nil ? nil : currency))
|
||||||
|
if let data = docData, let first = created.first {
|
||||||
|
_ = try? await APIClient.shared.uploadItemDocument(
|
||||||
|
itemId: first.id, data: data,
|
||||||
|
filename: docName.isEmpty ? "beleg" : docName, contentType: docType)
|
||||||
|
}
|
||||||
await onDone()
|
await onDone()
|
||||||
dismiss()
|
dismiss()
|
||||||
} catch { self.error = error.localizedDescription }
|
} catch { self.error = error.localizedDescription }
|
||||||
|
|||||||
@@ -712,25 +712,46 @@ struct Item: Codable, Identifiable, Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Antwort nach dem Beleg-Upload – mit den aus dem PDF geschätzten Werten.
|
/// Antwort nach dem Beleg-Upload – mit den aus dem PDF geschätzten Werten.
|
||||||
struct ItemDocumentUpload: Codable {
|
/// Aus einem Beleg-PDF geschätzte Werte (Analyse ohne Speichern).
|
||||||
let id: Int
|
struct DocumentSuggestions: Codable {
|
||||||
let suggestedWarrantyUntil: String?
|
var suggestedWarrantyUntil: String? = nil
|
||||||
let suggestedPriceCents: Int?
|
var suggestedPriceCents: Int? = nil
|
||||||
let suggestedPriceCandidates: [Int]
|
var suggestedPriceCandidates: [Int] = []
|
||||||
|
var suggestedAcquiredOn: String? = nil
|
||||||
|
var suggestedShopId: Int? = nil
|
||||||
|
var suggestedShopName: String? = nil
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
|
||||||
case suggestedWarrantyUntil = "suggested_warranty_until"
|
case suggestedWarrantyUntil = "suggested_warranty_until"
|
||||||
case suggestedPriceCents = "suggested_price_cents"
|
case suggestedPriceCents = "suggested_price_cents"
|
||||||
case suggestedPriceCandidates = "suggested_price_candidates"
|
case suggestedPriceCandidates = "suggested_price_candidates"
|
||||||
|
case suggestedAcquiredOn = "suggested_acquired_on"
|
||||||
|
case suggestedShopId = "suggested_shop_id"
|
||||||
|
case suggestedShopName = "suggested_shop_name"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
id = try c.decode(Int.self, forKey: .id)
|
|
||||||
suggestedWarrantyUntil = try c.decodeIfPresent(String.self, forKey: .suggestedWarrantyUntil)
|
suggestedWarrantyUntil = try c.decodeIfPresent(String.self, forKey: .suggestedWarrantyUntil)
|
||||||
suggestedPriceCents = try c.decodeIfPresent(Int.self, forKey: .suggestedPriceCents)
|
suggestedPriceCents = try c.decodeIfPresent(Int.self, forKey: .suggestedPriceCents)
|
||||||
suggestedPriceCandidates = try c.decodeIfPresent([Int].self, forKey: .suggestedPriceCandidates) ?? []
|
suggestedPriceCandidates = try c.decodeIfPresent([Int].self, forKey: .suggestedPriceCandidates) ?? []
|
||||||
|
suggestedAcquiredOn = try c.decodeIfPresent(String.self, forKey: .suggestedAcquiredOn)
|
||||||
|
suggestedShopId = try c.decodeIfPresent(Int.self, forKey: .suggestedShopId)
|
||||||
|
suggestedShopName = try c.decodeIfPresent(String.self, forKey: .suggestedShopName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ItemDocumentUpload: Codable {
|
||||||
|
let id: Int
|
||||||
|
let suggestions: DocumentSuggestions
|
||||||
|
|
||||||
|
enum IdKey: String, CodingKey { case id }
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
id = try decoder.container(keyedBy: IdKey.self).decode(Int.self, forKey: .id)
|
||||||
|
suggestions = try DocumentSuggestions(from: decoder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -741,13 +762,16 @@ struct ItemCreateRequest: Codable {
|
|||||||
let acquiredOn: String?
|
let acquiredOn: String?
|
||||||
let warrantyUntil: String?
|
let warrantyUntil: String?
|
||||||
let note: String?
|
let note: String?
|
||||||
|
var priceCents: Int? = nil
|
||||||
|
var currency: String? = nil
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case count, note
|
case count, note, currency
|
||||||
case locationId = "location_id"
|
case locationId = "location_id"
|
||||||
case shopId = "shop_id"
|
case shopId = "shop_id"
|
||||||
case acquiredOn = "acquired_on"
|
case acquiredOn = "acquired_on"
|
||||||
case warrantyUntil = "warranty_until"
|
case warrantyUntil = "warranty_until"
|
||||||
|
case priceCents = "price_cents"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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