iOS: Kaufpreis + Belege am Einzelstück

ItemEditView bekommt einen Kaufpreis (Betrag + CHF/EUR) und eine Beleg-Sektion:
Rechnung/Garantieschein als Bild (Fotoauswahl) oder PDF (Datei-Import)
hochladen, ansehen (QuickLook) und loeschen. Nach einem PDF-Upload schlaegt der
Server "Garantie bis" und Kaufpreis vor – per Knopf uebernehmbar. Item-Model um
priceCents/currency/documents erweitert; Client-Methoden fuer Upload, Download
und Loeschen der Belege.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-26 21:02:58 +02:00
parent b9709d29bf
commit f8c0cec918
3 changed files with 211 additions and 3 deletions

View File

@@ -182,6 +182,36 @@ actor APIClient {
try await sendNoContent(request) try await sendNoContent(request)
} }
func uploadItemDocument(itemId: Int, data: Data, filename: String,
contentType: String) async throws -> ItemDocumentUpload {
var request = try makeRequest("/items/\(itemId)/documents", 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: ItemDocumentUpload.self)
}
func itemDocumentData(itemId: Int, docId: Int) async throws -> Data {
let request = try makeRequest("/items/\(itemId)/documents/\(docId)")
let (data, response) = try await URLSession.shared.data(for: request)
try check(response, data: data)
return data
}
func deleteItemDocument(itemId: Int, docId: Int) async throws {
try await sendNoContent(
try makeRequest("/items/\(itemId)/documents/\(docId)", method: "DELETE"))
}
func deleteItem(id: Int) async throws { func deleteItem(id: Int) async throws {
try await sendNoContent(try makeRequest("/items/\(id)", method: "DELETE")) try await sendNoContent(try makeRequest("/items/\(id)", method: "DELETE"))
} }

View File

@@ -1,5 +1,8 @@
import SwiftUI import SwiftUI
import CoreImage import CoreImage
import PhotosUI
import QuickLook
import UniformTypeIdentifiers
/// QR-Code als Bild (CoreImage). Inhalt ist der Link auf das Einzelstück, damit /// QR-Code als Bild (CoreImage). Inhalt ist der Link auf das Einzelstück, damit
/// ein Scan auch mit der Systemkamera die App/Weboberfläche öffnet. /// ein Scan auch mit der Systemkamera die App/Weboberfläche öffnet.
@@ -96,11 +99,20 @@ struct ItemEditView: 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 documents: [ItemDocument] = []
@State private var suggWarranty: String?
@State private var suggPrice: Int?
@State private var busy = false @State private var busy = false
@State private var busyDoc = false
@State private var error: String? @State private var error: String?
@State private var showRemove = false @State private var showRemove = false
@State private var reason = "broken" @State private var reason = "broken"
@State private var removeNote = "" @State private var removeNote = ""
@State private var showFileImporter = false
@State private var pickerItem: PhotosPickerItem?
@State private var previewURL: URL?
var body: some View { var body: some View {
Form { Form {
@@ -136,6 +148,52 @@ struct ItemEditView: View {
LabeledField(label: "Notiz", text: $note) LabeledField(label: "Notiz", text: $note)
} }
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("Belege (Rechnung/Garantieschein)") {
if suggWarranty != nil || suggPrice != nil {
VStack(alignment: .leading, spacing: 6) {
Text("Im Beleg erkannt:").font(.caption).foregroundStyle(.secondary)
if let w = suggWarranty { Text("Garantie bis \(w)") }
if let p = suggPrice { Text("Kaufpreis \(ItemEditView.formatCents(p)) \(currency)") }
HStack {
Button("Übernehmen") { applySuggestion() }
Spacer()
Button("Verwerfen") { suggWarranty = nil; suggPrice = nil }
.foregroundStyle(.secondary)
}
.font(.callout)
}
}
ForEach(documents) { d in
Button { Task { await openDocument(d) } } label: {
Label(d.filename, systemImage: "doc.text")
}
.swipeActions(edge: .trailing) {
Button("Löschen", role: .destructive) { Task { await deleteDoc(d) } }
}
}
if documents.isEmpty {
Text("Noch keine Belege.").foregroundStyle(.secondary)
}
PhotosPicker(selection: $pickerItem, matching: .images) {
Label("Bild hochladen", systemImage: "photo")
}
Button { showFileImporter = true } label: {
Label("PDF hochladen", systemImage: "doc.badge.plus")
}
if busyDoc { ProgressView() }
}
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
Section { Section {
@@ -150,6 +208,30 @@ struct ItemEditView: View {
.sheet(isPresented: $showRemove) { .sheet(isPresented: $showRemove) {
NavigationStack { removeSheet } NavigationStack { removeSheet }
} }
.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 upload(data: data, filename: url.lastPathComponent,
contentType: "application/pdf")
}
}
} else if case .failure(let err) = result {
error = err.localizedDescription
}
}
.onChange(of: pickerItem) { neu in
guard let neu else { return }
Task {
if let data = try? await neu.loadTransferable(type: Data.self) {
await upload(data: data, filename: "foto.jpg", contentType: "image/jpeg")
}
pickerItem = nil
}
}
.quickLookPreview($previewURL)
} }
private var removeSheet: some View { private var removeSheet: some View {
@@ -174,23 +256,84 @@ struct ItemEditView: View {
locationId = item.locationId locationId = item.locationId
shopId = item.shopId shopId = item.shopId
note = item.note ?? "" note = item.note ?? ""
documents = item.documents
currency = item.currency ?? "CHF"
if let p = item.priceCents { priceText = ItemEditView.formatCents(p) }
if let s = item.acquiredOn, let d = stringToDate(s) { acquired = d; hasAcquired = true } if let s = item.acquiredOn, let d = stringToDate(s) { acquired = d; hasAcquired = true }
if let s = item.warrantyUntil, let d = stringToDate(s) { warranty = d; hasWarranty = true } if let s = item.warrantyUntil, let d = stringToDate(s) { warranty = d; hasWarranty = true }
} }
private func save() async { private func save() async {
busy = true; defer { busy = false }; error = nil busy = true; defer { busy = false }; error = nil
let cents = ItemEditView.parseCents(priceText)
do { do {
_ = try await APIClient.shared.updateItem(id: item.id, ItemUpdateRequest( _ = try await APIClient.shared.updateItem(id: item.id, ItemUpdateRequest(
locationId: locationId, shopId: shopId, locationId: locationId, shopId: shopId,
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))
onChanged?() onChanged?()
dismiss() dismiss()
} catch { self.error = error.localizedDescription } } catch { self.error = error.localizedDescription }
} }
// MARK: - Belege
private func upload(data: Data, filename: String, contentType: String) async {
busyDoc = true
defer { busyDoc = false }
do {
let res = try await APIClient.shared.uploadItemDocument(
itemId: item.id, data: data, filename: filename, contentType: contentType)
suggWarranty = res.suggestedWarrantyUntil
suggPrice = res.suggestedPriceCents
await reloadDocuments()
} catch { self.error = error.localizedDescription }
}
private func reloadDocuments() async {
if let fresh = try? await APIClient.shared.itemByUid(uid: item.uid) {
documents = fresh.documents
}
}
private func openDocument(_ d: ItemDocument) async {
do {
let data = try await APIClient.shared.itemDocumentData(itemId: item.id, docId: d.id)
let name = d.filename.isEmpty ? "beleg" : d.filename
let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
try data.write(to: url)
previewURL = url
} catch { self.error = error.localizedDescription }
}
private func deleteDoc(_ d: ItemDocument) async {
do {
try await APIClient.shared.deleteItemDocument(itemId: item.id, docId: d.id)
await reloadDocuments()
} catch { self.error = error.localizedDescription }
}
private func applySuggestion() {
if let w = suggWarranty, let d = stringToDate(w) { warranty = d; hasWarranty = true }
if let p = suggPrice { priceText = ItemEditView.formatCents(p) }
suggWarranty = nil
suggPrice = nil
}
/// Eingabe in Hauptwährungseinheit Rappen/Cent.
static func parseCents(_ s: String) -> Int? {
let t = s.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: ",", with: ".")
guard !t.isEmpty, let v = Double(t) else { return nil }
return Int((v * 100).rounded())
}
static func formatCents(_ c: Int) -> String {
String(format: "%.2f", Double(c) / 100)
}
private func remove() async { private func remove() async {
do { do {
try await APIClient.shared.removeItem(id: item.id, ItemRemoveRequest( try await APIClient.shared.removeItem(id: item.id, ItemRemoveRequest(

View File

@@ -583,6 +583,19 @@ enum RemovalReasons {
// MARK: - Einzelstücke (Items mit UID/QR) // MARK: - Einzelstücke (Items mit UID/QR)
struct ItemDocument: Codable, Identifiable, Hashable {
let id: Int
let filename: String
let contentType: String
let uploadedAt: String
enum CodingKeys: String, CodingKey {
case id, filename
case contentType = "content_type"
case uploadedAt = "uploaded_at"
}
}
struct Item: Codable, Identifiable, Hashable { struct Item: Codable, Identifiable, Hashable {
let id: Int let id: Int
let uid: String let uid: String
@@ -594,12 +607,15 @@ struct Item: Codable, Identifiable, Hashable {
let acquiredOn: String? // "yyyy-MM-dd" let acquiredOn: String? // "yyyy-MM-dd"
let warrantyUntil: String? let warrantyUntil: String?
let note: String? let note: String?
let priceCents: Int?
let currency: String?
let createdAt: String let createdAt: String
let productName: String? let productName: String?
let productBrand: String? let productBrand: String?
let documents: [ItemDocument]
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, uid, note case id, uid, note, currency, documents
case productId = "product_id" case productId = "product_id"
case locationId = "location_id" case locationId = "location_id"
case locationName = "location_name" case locationName = "location_name"
@@ -607,12 +623,26 @@ struct Item: Codable, Identifiable, Hashable {
case shopName = "shop_name" case shopName = "shop_name"
case acquiredOn = "acquired_on" case acquiredOn = "acquired_on"
case warrantyUntil = "warranty_until" case warrantyUntil = "warranty_until"
case priceCents = "price_cents"
case createdAt = "created_at" case createdAt = "created_at"
case productName = "product_name" case productName = "product_name"
case productBrand = "product_brand" case productBrand = "product_brand"
} }
} }
/// Antwort nach dem Beleg-Upload mit den aus dem PDF geschätzten Werten.
struct ItemDocumentUpload: Codable {
let id: Int
let suggestedWarrantyUntil: String?
let suggestedPriceCents: Int?
enum CodingKeys: String, CodingKey {
case id
case suggestedWarrantyUntil = "suggested_warranty_until"
case suggestedPriceCents = "suggested_price_cents"
}
}
struct ItemCreateRequest: Codable { struct ItemCreateRequest: Codable {
let count: Int let count: Int
let locationId: Int? let locationId: Int?
@@ -636,13 +666,16 @@ struct ItemUpdateRequest: Encodable {
var acquiredOn: String? var acquiredOn: String?
var warrantyUntil: String? var warrantyUntil: String?
var note: String? var note: String?
var priceCents: Int?
var currency: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case note case 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"
} }
// Immer alle Felder senden (auch null), damit sich ein Feld leeren lässt. // Immer alle Felder senden (auch null), damit sich ein Feld leeren lässt.
@@ -653,6 +686,8 @@ struct ItemUpdateRequest: Encodable {
try c.encode(acquiredOn, forKey: .acquiredOn) try c.encode(acquiredOn, forKey: .acquiredOn)
try c.encode(warrantyUntil, forKey: .warrantyUntil) try c.encode(warrantyUntil, forKey: .warrantyUntil)
try c.encode(note, forKey: .note) try c.encode(note, forKey: .note)
try c.encode(priceCents, forKey: .priceCents)
try c.encode(currency, forKey: .currency)
} }
} }