import SwiftUI /// Zuordnen per Scan: erst ein Einzelstück (…/i/) scannen, dann einen /// Lagerort (…/l/) – das Stück wird dem Ort zugewiesen. Danach gleich das /// nächste Stück, ohne Tippen. struct AssignScanView: View { @Environment(\.dismiss) private var dismiss @State private var paused = false @State private var torchOn = false @State private var torchLocked = false @State private var pending: Item? // gescanntes/gewähltes Stück, wartet auf Ort @State private var status: String? @State private var error: String? @State private var showPicker = false var body: some View { ScrollView { VStack(spacing: 14) { ScannerView(onCode: { code in Task { await handle(code) } }, isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked) .aspectRatio(4.0 / 3.0, contentMode: .fit) .clipShape(RoundedRectangle(cornerRadius: 16)) .padding(.horizontal) if let pending { EinzelstueckKarte(item: pending) { self.pending = nil } } else { VStack(spacing: 8) { Text("Schritt 1: Einzelstück-QR scannen – oder ohne Code:") .font(.caption).foregroundStyle(.secondary) // Kleidung trägt keinen QR, aber ein eingenähtes NFC-Tag. NFCLesenButton(titel: "Einzelstück-Tag lesen") { uid in Task { let gefunden = await waehle(uid: uid) if !gefunden { error = "Kein Einzelstück mit der UID \(uid)." status = nil } } } onFehler: { meldung in error = meldung; status = nil } // Für Stücke ohne Code jeder Art: aus der Liste wählen. Button { showPicker = true } label: { Label("Einzelstück aus Liste wählen", systemImage: "list.bullet") } } } if let status { Text(status).font(.callout).foregroundStyle(.green) .frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal) } if let error { Text(error).font(.callout).foregroundStyle(.red) .frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal) } } .padding(.vertical) } .navigationTitle("Ort zuordnen") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } } ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) } } .sheet(isPresented: $showPicker) { ItemPickerSheet { it in pending = it; status = nil; error = nil } } } private func handle(_ code: String) async { // Lagerort-QR (…/l/)? Der Code ist eine 10-stellige Zeichenkette. if let r = code.range(of: "/l/") { let locCode = String(code[r.upperBound...]) .trimmingCharacters(in: CharacterSet(charactersIn: "/ ")) .uppercased() if !locCode.isEmpty { guard let it = pending else { error = "Erst ein Einzelstück scannen."; status = nil; return } do { // Alle Felder mitsenden: ItemUpdateRequest schreibt auch null // mit, damit sich Felder leeren lassen. Kaufpreis und Waehrung // fehlten hier – ein Zuordnen per Scan hat sie stillschweigend // geloescht. _ = try await APIClient.shared.updateItem(id: it.id, ItemUpdateRequest( locationId: locCode, shopId: it.shopId, acquiredOn: it.acquiredOn, warrantyUntil: it.warrantyUntil, note: it.note, priceCents: it.priceCents, currency: it.currency)) status = "\(it.uid) zugeordnet. Nächstes Stück scannen." error = nil pending = nil } catch { self.error = error.localizedDescription } return } } // Einzelstück-QR (…/i/)? if let uid = einzelstueckCode(aus: code), await waehle(uid: uid) { return } error = "Unbekannter Code – bitte einen Einzelstück- oder Lagerort-QR scannen." } /// Einzelstück als „wartet auf Lagerort" vormerken – aus Scan, NFC oder Liste. /// Gibt zurueck, ob es gefunden wurde; der Aufrufer entscheidet ueber die /// Fehlermeldung, weil ein Scan noch andere Deutungen zulaesst als ein Tag. @discardableResult private func waehle(uid: String) async -> Bool { guard let it = try? await APIClient.shared.itemByUid(uid: uid) else { return false } pending = it status = nil error = nil return true } } /// Was gerade in der Hand liegt und auf einen Lagerort wartet. /// /// Die UID allein sagt nichts darueber, was man da haelt - gerade bei Kleidung, /// wo das NFC-Tag eingenaeht und unsichtbar ist. Unter dem Sucher ist reichlich /// Platz, also zeigt die Karte Bild, Name, Marke und den aktuellen Ort. private struct EinzelstueckKarte: View { let item: Item var onAbbrechen: () -> Void var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .top, spacing: 12) { ProductThumb(productId: item.productId, groesse: 64) VStack(alignment: .leading, spacing: 3) { Text(item.productName ?? "Unbekannter Artikel") .font(.headline) if let marke = item.productBrand, !marke.isEmpty { Text(marke).font(.subheadline).foregroundStyle(.secondary) } if let notiz = item.note, !notiz.isEmpty { Text(notiz).font(.caption).foregroundStyle(.secondary).lineLimit(2) } } Spacer(minLength: 0) } Divider() zeile("Einzelstücknummer") { Text(item.uid).font(.callout.monospaced()) } zeile("Derzeit") { Text(item.locationName ?? "ohne Lagerort").multilineTextAlignment(.trailing) } Divider() HStack { Text("Jetzt den Lagerort-QR scannen") .font(.caption).foregroundStyle(.secondary) Spacer() Button("Abbrechen", action: onAbbrechen).font(.caption) } } .padding().frame(maxWidth: .infinity) .background(Color.marke.opacity(0.15)) .clipShape(RoundedRectangle(cornerRadius: 12)).padding(.horizontal) } private func zeile( _ titel: String, @ViewBuilder inhalt: () -> Inhalt ) -> some View { HStack { Text(titel).foregroundStyle(.secondary) Spacer() inhalt() } .font(.callout) } } /// Einzelstück aus einer durchsuchbaren Liste wählen – für Stücke ohne eigenen QR. struct ItemPickerSheet: View { var onPick: (Item) -> Void @Environment(\.dismiss) private var dismiss @State private var items: [Item] = [] @State private var locations: [StorageLocation] = [] @State private var query = "" @State private var busy = true private var gefiltert: [Item] { let q = query.trimmingCharacters(in: .whitespaces).lowercased() guard !q.isEmpty else { return items } return items.filter { [$0.productName, $0.uid, $0.productBrand, $0.note] .compactMap { $0 }.joined(separator: " ").lowercased().contains(q) } } var body: some View { NavigationStack { List { if busy && items.isEmpty { ProgressView() } ForEach(gefiltert) { it in Button { onPick(it); dismiss() } label: { ItemRow(item: it, locations: locations) } .buttonStyle(.plain) } if !busy && gefiltert.isEmpty { Text("Nichts gefunden.").foregroundStyle(.secondary) } } .searchable(text: $query, prompt: "Produkt, UID oder Notiz") .navigationTitle("Einzelstück wählen") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } } .task { items = (try? await APIClient.shared.allItems()) ?? [] locations = (try? await APIClient.shared.locations()) ?? [] busy = false } } } }