import SwiftUI import CoreImage /// 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 busy = false @State private var error: String? @State private var showRemove = false @State private var reason = "broken" @State private var removeNote = "" 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) } 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 } } } 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 ?? "" 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 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)) onChanged?() dismiss() } catch { self.error = error.localizedDescription } } 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 } } }