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) } /// Datumszeile mit einheitlichem Format (dd.MM.yyyy). Die native compact-DatePicker /// zeigt das Jahr je nach Feld mal zwei-, mal vierstellig – deshalb hier ein fester /// Formatierer und ein grafischer Picker im Popover. struct DateRow: View { let label: String @Binding var date: Date @State private var open = false private static let fmt: DateFormatter = { let f = DateFormatter() f.locale = Locale(identifier: "de_DE") f.dateFormat = "dd.MM.yyyy" return f }() var body: some View { Group { Button { withAnimation(.easeInOut(duration: 0.15)) { open.toggle() } } label: { HStack { Text(label).foregroundStyle(.primary) Spacer() Text(Self.fmt.string(from: date)) .foregroundStyle(open ? Color.marke : .secondary) } .contentShape(Rectangle()) } .buttonStyle(.plain) if open { DatePicker(label, selection: $date, displayedComponents: .date) .datePickerStyle(.graphical) .labelsHidden() } } } } // 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 /// Bis zur Liste schließen (für „Anlegen & fertig"). var onFinish: (() -> Void)? = nil @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, onDone: { await refresh() }, onFinish: onFinish.map { finish in { showAdd = false; finish() } }) } } } 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) /// Lagerort per QR (…/l/) auswählen – selbstständige Scan-Ansicht, meldet /// den getroffenen Lagerort zurück. So kann man einen Ort auch ohne den QR des /// Einzelstücks setzen (z.B. bei Kleidung ohne eigenen Code). struct LocationScannerView: View { let locations: [StorageLocation] var onPicked: (StorageLocation) -> Void @Environment(\.dismiss) private var dismiss @State private var paused = false @State private var torchOn = false @State private var torchLocked = false @State private var error: String? var body: some View { NavigationStack { ScannerView(onCode: { code in handle(code) }, isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked) .ignoresSafeArea(edges: .bottom) .overlay(alignment: .bottom) { VStack(spacing: 6) { if let error { Text(error) .font(.callout).foregroundStyle(.white) .padding(.horizontal, 12).padding(.vertical, 8) .background(.red, in: Capsule()) } Text("QR am Regal/Fach vor die Kamera halten") .font(.callout).padding(10) .background(.ultraThinMaterial, in: Capsule()) } .padding(.bottom, 24) } .navigationTitle("Lagerort scannen") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) } } } } private func handle(_ code: String) { guard let r = code.range(of: "/l/") else { error = "Das ist kein Lagerort-QR." return } let ziel = String(code[r.upperBound...]) .trimmingCharacters(in: CharacterSet(charactersIn: "/ ")) .uppercased() if let treffer = locations.first(where: { $0.id == ziel }) { onPicked(treffer) dismiss() } else { error = "Dieser Lagerort ist hier nicht bekannt." } } } 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: String? @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 suggAcquired: String? @State private var suggShopId: Int? @State private var suggShopName: String? @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? @State private var locScanShown = false 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(String?.none) ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) } } // Lagerort per QR setzen – ohne den (evtl. nicht vorhandenen) QR // des Einzelstücks. Praktisch z.B. bei Kleidung. Button { locScanShown = true } label: { Label("Lagerort scannen", systemImage: "qrcode.viewfinder") } 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 { DateRow(label: "Gekauft am", date: $acquired) } Toggle("Garantie", isOn: $hasWarranty) if hasWarranty { DateRow(label: "Garantie bis", date: $warranty) } 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 hatVorschlag { VStack(alignment: .leading, spacing: 6) { 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 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)") } if let sid = suggShopId { Text("Shop: \(shops.first(where: { $0.id == sid })?.name ?? "bekannt")") } else if let name = suggShopName { Text("Shop anlegen: \(name)") } HStack { Button("Übernehmen") { Task { await 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() } .fullScreenCover(isPresented: $locScanShown) { LocationScannerView(locations: locations) { loc in locationId = loc.id } .ignoresSafeArea() } .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) let s = res.suggestions suggWarranty = s.suggestedWarrantyUntil suggPrice = s.suggestedPriceCents suggPriceCandidates = s.suggestedPriceCandidates suggAcquired = s.suggestedAcquiredOn suggShopId = s.suggestedShopId suggShopName = s.suggestedShopName 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 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 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() } private func verwerfeVorschlag() { suggWarranty = nil suggPrice = nil suggPriceCandidates = [] suggAcquired = nil suggShopId = nil suggShopName = 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( 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 /// Wenn gesetzt: „Anlegen & fertig" schließt bis zur Liste (nicht nur das Sheet). var onFinish: (() -> Void)? = nil @Environment(\.dismiss) private var dismiss @State private var count = 1 @State private var locations: [StorageLocation] = [] @State private var shops: [ShopItem] = [] @State private var locationId: String? @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 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 error: String? @State private var locScanShown = false var body: some View { Form { Section { Stepper("Anzahl: \(count)", value: $count, in: 1...200) Picker("Lagerort", selection: $locationId) { Text("– ohne –").tag(String?.none) ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) } } Button { locScanShown = true } label: { Label("Lagerort scannen", systemImage: "qrcode.viewfinder") } 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 { DateRow(label: "Gekauft am", date: $acquired) } Toggle("Garantie", isOn: $hasWarranty) if hasWarranty { DateRow(label: "Garantie bis", date: $warranty) } LabeledField(label: "Notiz", text: $note) } footer: { 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) } } Section { Button(busy ? "Anlegen…" : "Anlegen") { Task { await create(finish: false) } }.disabled(busy) if onFinish != nil { Button(busy ? "Anlegen…" : "Anlegen & fertig") { Task { await create(finish: true) } } .disabled(busy) } } } .navigationTitle("Einzelstücke anlegen") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } } .fullScreenCover(isPresented: $locScanShown) { LocationScannerView(locations: locations) { loc in locationId = loc.id } .ignoresSafeArea() } .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 { locations = (try? await APIClient.shared.locations()) ?? [] 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(finish: Bool) async { busy = true; defer { busy = false }; error = nil do { var sid = 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, warrantyUntil: hasWarranty ? dateToString(warranty) : nil, 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() if finish, let onFinish { onFinish() } else { dismiss() } } catch { self.error = error.localizedDescription } } }