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)
|
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"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user