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:
@@ -182,6 +182,36 @@ actor APIClient {
|
||||
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 {
|
||||
try await sendNoContent(try makeRequest("/items/\(id)", method: "DELETE"))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import SwiftUI
|
||||
import CoreImage
|
||||
import PhotosUI
|
||||
import QuickLook
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// 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.
|
||||
@@ -96,11 +99,20 @@ struct ItemEditView: View {
|
||||
@State private var hasWarranty = false
|
||||
@State private var warranty = Date()
|
||||
@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 busyDoc = false
|
||||
@State private var error: String?
|
||||
@State private var showRemove = false
|
||||
@State private var reason = "broken"
|
||||
@State private var removeNote = ""
|
||||
@State private var showFileImporter = false
|
||||
@State private var pickerItem: PhotosPickerItem?
|
||||
@State private var previewURL: URL?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
@@ -136,6 +148,52 @@ struct ItemEditView: View {
|
||||
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) } }
|
||||
|
||||
Section {
|
||||
@@ -150,6 +208,30 @@ struct ItemEditView: View {
|
||||
.sheet(isPresented: $showRemove) {
|
||||
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 {
|
||||
@@ -174,23 +256,84 @@ struct ItemEditView: View {
|
||||
locationId = item.locationId
|
||||
shopId = item.shopId
|
||||
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.warrantyUntil, let d = stringToDate(s) { warranty = d; hasWarranty = true }
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
busy = true; defer { busy = false }; error = nil
|
||||
let cents = ItemEditView.parseCents(priceText)
|
||||
do {
|
||||
_ = try await APIClient.shared.updateItem(id: item.id, ItemUpdateRequest(
|
||||
locationId: locationId, shopId: shopId,
|
||||
acquiredOn: hasAcquired ? dateToString(acquired) : nil,
|
||||
warrantyUntil: hasWarranty ? dateToString(warranty) : nil,
|
||||
note: note.isEmpty ? nil : note))
|
||||
note: note.isEmpty ? nil : note,
|
||||
priceCents: cents,
|
||||
currency: cents == nil ? nil : currency))
|
||||
onChanged?()
|
||||
dismiss()
|
||||
} 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 {
|
||||
do {
|
||||
try await APIClient.shared.removeItem(id: item.id, ItemRemoveRequest(
|
||||
|
||||
@@ -583,6 +583,19 @@ enum RemovalReasons {
|
||||
|
||||
// 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 {
|
||||
let id: Int
|
||||
let uid: String
|
||||
@@ -594,12 +607,15 @@ struct Item: Codable, Identifiable, Hashable {
|
||||
let acquiredOn: String? // "yyyy-MM-dd"
|
||||
let warrantyUntil: String?
|
||||
let note: String?
|
||||
let priceCents: Int?
|
||||
let currency: String?
|
||||
let createdAt: String
|
||||
let productName: String?
|
||||
let productBrand: String?
|
||||
let documents: [ItemDocument]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, uid, note
|
||||
case id, uid, note, currency, documents
|
||||
case productId = "product_id"
|
||||
case locationId = "location_id"
|
||||
case locationName = "location_name"
|
||||
@@ -607,12 +623,26 @@ struct Item: Codable, Identifiable, Hashable {
|
||||
case shopName = "shop_name"
|
||||
case acquiredOn = "acquired_on"
|
||||
case warrantyUntil = "warranty_until"
|
||||
case priceCents = "price_cents"
|
||||
case createdAt = "created_at"
|
||||
case productName = "product_name"
|
||||
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 {
|
||||
let count: Int
|
||||
let locationId: Int?
|
||||
@@ -636,13 +666,16 @@ struct ItemUpdateRequest: Encodable {
|
||||
var acquiredOn: String?
|
||||
var warrantyUntil: String?
|
||||
var note: String?
|
||||
var priceCents: Int?
|
||||
var currency: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case note
|
||||
case note, currency
|
||||
case locationId = "location_id"
|
||||
case shopId = "shop_id"
|
||||
case acquiredOn = "acquired_on"
|
||||
case warrantyUntil = "warranty_until"
|
||||
case priceCents = "price_cents"
|
||||
}
|
||||
|
||||
// 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(warrantyUntil, forKey: .warrantyUntil)
|
||||
try c.encode(note, forKey: .note)
|
||||
try c.encode(priceCents, forKey: .priceCents)
|
||||
try c.encode(currency, forKey: .currency)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user