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. enum QRImage { static func make(_ text: String, scale: CGFloat = 8) -> UIImage? { guard let data = text.data(using: .utf8), let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil } filter.setValue(data, forKey: "inputMessage") filter.setValue("M", forKey: "inputCorrectionLevel") guard let ci = filter.outputImage? .transformed(by: CGAffineTransform(scaleX: scale, y: scale)) else { return nil } let context = CIContext() guard let cg = context.createCGImage(ci, from: ci.extent) else { return nil } return UIImage(cgImage: cg) } } func qrLink(forUid uid: String) -> String { let base = Session.shared.baseURL?.absoluteString .trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? "" return base.isEmpty ? uid : "\(base)/i/\(uid)" } private func dateToString(_ d: Date) -> String { let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.string(from: d) } private func stringToDate(_ s: String) -> Date? { let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.date(from: s) } // MARK: - Liste (in der Produkt-Detailansicht) /// Einzelstücke eines Gegenstands: Liste mit Anlegen; jedes Stück öffnet die /// Detail-/Bearbeiten-Ansicht. struct ProductItemsSection: View { let product: Product var onChanged: () async -> Void @State private var items: [Item] = [] @State private var showAdd = false var body: some View { Section("Einzelstücke (\(items.count))") { ForEach(items) { it in NavigationLink { ItemEditView(item: it) { Task { await refresh() } } } label: { VStack(alignment: .leading, spacing: 2) { Text(it.uid).font(.body.monospaced()).bold() Text(untertitel(it)).font(.caption).foregroundStyle(.secondary) } } } if items.isEmpty { Text("Noch keine Einzelstücke.").foregroundStyle(.secondary) } Button { showAdd = true } label: { Label("Einzelstücke anlegen", systemImage: "plus") } } .task(id: product.id) { await reload() } .sheet(isPresented: $showAdd) { NavigationStack { ItemAddSheet(productId: product.id) { await refresh() } } } } private func untertitel(_ it: Item) -> String { [it.locationName, it.acquiredOn.map { "gekauft \($0)" }, it.shopName] .compactMap { $0 }.joined(separator: " · ") } private func reload() async { items = (try? await APIClient.shared.items(productId: product.id)) ?? [] } private func refresh() async { await reload(); await onChanged() } } // MARK: - Detail / Bearbeiten (auch Ziel eines Scans) struct ItemEditView: View { let item: Item var onChanged: (() -> Void)? = nil @Environment(\.dismiss) private var dismiss @State private var locations: [StorageLocation] = [] @State private var shops: [ShopItem] = [] @State private var photo: Data? @State private var locationId: Int? @State private var shopId: Int? @State private var hasAcquired = false @State private var acquired = Date() @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 suggPriceCandidates: [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 { Section { if let photo, let ui = UIImage(data: photo) { Image(uiImage: ui) .resizable().scaledToFit() .frame(maxWidth: .infinity).frame(maxHeight: 220) .clipShape(RoundedRectangle(cornerRadius: 8)) } if let name = item.productName { LabeledContent("Artikel", value: name) } if let brand = item.productBrand, !brand.isEmpty { LabeledContent("Marke", value: brand) } LabeledContent("UID", value: item.uid) } Section { Picker("Lagerort", selection: $locationId) { Text("– ohne –").tag(Int?.none) ForEach(locations) { l in Text(l.name).tag(Int?.some(l.id)) } } Picker("Gekauft bei", selection: $shopId) { Text("– unbekannt –").tag(Int?.none) ForEach(shops) { s in Text(s.name).tag(Int?.some(s.id)) } } Toggle("Kaufdatum", isOn: $hasAcquired) if hasAcquired { DatePicker("Gekauft am", selection: $acquired, displayedComponents: .date) } Toggle("Garantie", isOn: $hasWarranty) if hasWarranty { DatePicker("Garantie bis", selection: $warranty, displayedComponents: .date) } 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 suggPriceCandidates.count > 1 { // Mehrere Beträge gefunden – Auswahl anbieten. Picker("Kaufpreis", selection: Binding( get: { suggPrice ?? suggPriceCandidates.first ?? 0 }, set: { suggPrice = $0 } )) { ForEach(suggPriceCandidates, id: \.self) { c in Text("\(ItemEditView.formatCents(c)) \(currency)").tag(c) } } .pickerStyle(.menu) } else if let p = suggPrice { Text("Kaufpreis \(ItemEditView.formatCents(p)) \(currency)") } HStack { Button("Übernehmen") { applySuggestion() } Spacer() Button("Verwerfen") { verwerfeVorschlag() } .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 { Button(busy ? "Speichern…" : "Speichern") { Task { await save() } }.disabled(busy) Button("Entfernen (mit Grund)", role: .destructive) { showRemove = true } } } .navigationTitle("Einzelstück") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } } } .task { await load() } .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 { Form { Picker("Grund", selection: $reason) { ForEach(RemovalReasons.all, id: \.value) { Text($0.label).tag($0.value) } } LabeledField(label: "Notiz", text: $removeNote) Section { Button("Entfernen", role: .destructive) { Task { await remove() } } } } .navigationTitle("Entfernen") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { showRemove = false } } } } private func load() async { locations = (try? await APIClient.shared.locations()) ?? [] shops = (try? await APIClient.shared.shops()) ?? [] photo = try? await APIClient.shared.productImage(id: item.productId) 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, 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 suggPriceCandidates = res.suggestedPriceCandidates 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) } verwerfeVorschlag() } private func verwerfeVorschlag() { suggWarranty = nil suggPrice = nil suggPriceCandidates = [] } /// 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( reason: reason, note: removeNote.isEmpty ? nil : removeNote)) showRemove = false onChanged?() dismiss() } catch { self.error = error.localizedDescription; showRemove = false } } } // MARK: - Anlegen struct ItemAddSheet: View { let productId: Int var onDone: () async -> Void @Environment(\.dismiss) private var dismiss @State private var count = 1 @State private var locations: [StorageLocation] = [] @State private var shops: [ShopItem] = [] @State private var locationId: Int? @State private var shopId: Int? @State private var hasAcquired = false @State private var acquired = Date() @State private var hasWarranty = false @State private var warranty = Date() @State private var note = "" @State private var busy = false @State private var error: String? var body: some View { Form { Section { Stepper("Anzahl: \(count)", value: $count, in: 1...200) Picker("Lagerort", selection: $locationId) { Text("– ohne –").tag(Int?.none) ForEach(locations) { l in Text(l.name).tag(Int?.some(l.id)) } } Picker("Gekauft bei", selection: $shopId) { Text("– unbekannt –").tag(Int?.none) ForEach(shops) { s in Text(s.name).tag(Int?.some(s.id)) } } Toggle("Kaufdatum", isOn: $hasAcquired) if hasAcquired { DatePicker("Gekauft am", selection: $acquired, displayedComponents: .date) } Toggle("Garantie", isOn: $hasWarranty) if hasWarranty { DatePicker("Garantie bis", selection: $warranty, displayedComponents: .date) } LabeledField(label: "Notiz", text: $note) } footer: { Text("Gemeinsame Startwerte – danach je Stück änderbar. Jedes Stück bekommt eine eigene UID + QR.") } if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } Section { Button(busy ? "Anlegen…" : "Anlegen") { Task { await create() } }.disabled(busy) } } .navigationTitle("Einzelstücke anlegen") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } } .task { locations = (try? await APIClient.shared.locations()) ?? [] shops = (try? await APIClient.shared.shops()) ?? [] } } private func create() async { busy = true; defer { busy = false }; error = nil do { _ = try await APIClient.shared.createItems(productId: productId, ItemCreateRequest( count: count, locationId: locationId, shopId: shopId, acquiredOn: hasAcquired ? dateToString(acquired) : nil, warrantyUntil: hasWarranty ? dateToString(warranty) : nil, note: note.isEmpty ? nil : note)) await onDone() dismiss() } catch { self.error = error.localizedDescription } } }