import SwiftUI struct CheckInView: View { @Environment(\.dismiss) private var dismiss // Kamera bewusst nur auf Wunsch: „Einlagern“ öffnet ein Formular, kein Sucher. @State private var scanShown = false @State private var paused = false @State private var torchOn = false @State private var torchLocked = false @State private var status: String? @State private var error: String? @State private var product: Product? @State private var scannedItem: Item? @State private var suggestion: LookupResult.Suggestion? @State private var suggestedGroupId: Int? @State private var suggestedGroupName: String? @State private var suggestedCategoryId: Int? @State private var suggestedCategoryName: String? @State private var unknownCode: String? @State private var manualCodeShown = false @State private var manualCode = "" // Manuelle Artikelsuche (statt Scanzwang). @State private var query = "" @State private var results: [Product] = [] @State private var searching = false @State private var units: [Unit] = [] @State private var locations: [StorageLocation] = [] var body: some View { Form { if let status { Section { banner(status, color: .green) } } if let error { Section { banner(error, color: .red) } } Section { Button { error = nil; status = nil scanShown = true } label: { Label("EAN scannen", systemImage: "barcode.viewfinder") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) Button { manualCode = "" manualCodeShown = true } label: { Label("EAN von Hand eingeben", systemImage: "keyboard") } NavigationLink { ProductFormView(prefillBarcode: nil, groupId: nil) { created in product = created } } label: { Label("Neuen Artikel anlegen", systemImage: "plus") } } header: { Text("Neu erfassen") } footer: { Text("Die Kamera öffnet sich erst beim Tippen auf „EAN scannen“ – kein Zwang.") } if let suggestion { suggestionSection(suggestion) } else if let unknownCode { unknownSection(unknownCode) } Section { HStack { Image(systemName: "magnifyingglass").foregroundStyle(.secondary) TextField("Name oder Marke …", text: $query) .autocorrectionDisabled() if !query.isEmpty { Button { query = "" } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) } .buttonStyle(.plain) } } if searching { HStack { ProgressView(); Text("Suche …").foregroundStyle(.secondary) } } ForEach(results) { p in Button { error = nil; status = nil product = p } label: { VStack(alignment: .leading, spacing: 2) { Text(p.name).foregroundStyle(.primary) let unter = [p.brand, "Bestand: \(bestandText(p))"] .compactMap { $0 }.joined(separator: " · ") Text(unter).font(.caption).foregroundStyle(.secondary) } } } if !query.trimmingCharacters(in: .whitespaces).isEmpty && results.isEmpty && !searching { Text("Kein Artikel gefunden – oben neu anlegen.") .foregroundStyle(.secondary).font(.callout) } } header: { Text("Vorhandenen Artikel wählen") } } .navigationTitle("Einlagern") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } } } .task { units = (try? await APIClient.shared.units()) ?? [] locations = (try? await APIClient.shared.locations()) ?? [] } .task(id: query) { await search() } .fullScreenCover(isPresented: $scanShown) { scannerCover } .alert("EAN eingeben", isPresented: $manualCodeShown) { TextField("z.B. 8076809572569", text: $manualCode) .keyboardType(.numberPad) Button("Suchen") { let code = manualCode manualCode = "" Task { await resolve(code) } } Button("Abbrechen", role: .cancel) { } } .sheet(item: $product) { item in NavigationStack { if item.isIndividual { // Einzelstücke werden nicht als Charge eingelagert, sondern als // physische Exemplare (UID/QR) angelegt – die zählen den Bestand. ItemAddSheet(productId: item.id, onDone: { status = "\(item.name): Einzelstück(e) angelegt." }) } else { CheckInFormView(product: item, units: units, locations: locations) { message in status = message product = nil } } } } .sheet(item: $scannedItem) { it in NavigationStack { ItemEditView(item: it) } } } // MARK: - Scanner (nur auf Wunsch) private var scannerCover: some View { NavigationStack { ScannerView(onCode: { code in Task { await resolve(code) } }, isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked) .ignoresSafeArea(edges: .bottom) .overlay(alignment: .bottom) { Text("Barcode vor die Kamera halten") .font(.callout) .padding(10) .background(.ultraThinMaterial, in: Capsule()) .padding(.bottom, 24) } .navigationTitle("EAN scannen") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { scanShown = false } } ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) } } } .onAppear { paused = false } } // MARK: - Bausteine private func banner(_ text: String, color: Color) -> some View { Text(text) .font(.callout) .padding(10) .frame(maxWidth: .infinity, alignment: .leading) .background(color.opacity(0.9)) .foregroundStyle(.white) .clipShape(RoundedRectangle(cornerRadius: 10)) .listRowInsets(EdgeInsets()) .listRowBackground(Color.clear) } private func bestandText(_ p: Product) -> String { let wert = p.stockInArticleUnits let zahl = wert == wert.rounded() ? String(Int(wert)) : String(format: "%.2f", wert) return "\(zahl) \(p.articleUnitLabel)" } /// Sagt vor dem Anlegen, welche Gruppe der Artikel bekommt und warum. @ViewBuilder private func groupNote() -> some View { if let kategorie = suggestedCategoryName { noteRow(symbol: "square.grid.2x2", title: "Wird der Kategorie „\(kategorie)“ zugeordnet", detail: "Aus der Produkt-Datenbank vorgeschlagen – im Formular änderbar.") } if let name = suggestedGroupName { noteRow(symbol: "tag", title: "Wird der Gruppe „\(name)“ zugeordnet", detail: "Dieser EAN-Code ist dort hinterlegt.") } } private func noteRow(symbol: String, title: String, detail: String) -> some View { HStack(alignment: .top, spacing: 6) { Image(systemName: symbol) VStack(alignment: .leading, spacing: 1) { Text(title).font(.caption).bold() Text(detail).font(.caption2).foregroundStyle(.secondary) } } } @ViewBuilder private func suggestionSection(_ item: LookupResult.Suggestion) -> some View { Section { VStack(alignment: .leading, spacing: 8) { Text(item.name).font(.headline) Text([item.brand, item.quantityText].compactMap { $0 }.joined(separator: " · ")) .font(.caption).foregroundStyle(.secondary) Text("In einer Produkt-Datenbank gefunden, noch nicht im Katalog.") .font(.caption2).foregroundStyle(.secondary) groupNote() NavigationLink { ProductFormView(prefillBarcode: item.barcode, groupId: suggestedGroupId, categoryId: suggestedCategoryId, suggestion: item) { created in suggestion = nil product = created } } label: { Label("Anlegen & einlagern", systemImage: "plus.circle.fill") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) } } header: { Text("Vorschlag zum Scan") } } @ViewBuilder private func unknownSection(_ code: String) -> some View { Section { VStack(alignment: .leading, spacing: 8) { Text("Unbekannter Code \(code)").font(.headline) Text("Weder im Katalog noch in Open Food / Products Facts.") .font(.caption).foregroundStyle(.secondary) groupNote() NavigationLink { ProductFormView(prefillBarcode: code, groupId: suggestedGroupId, categoryId: suggestedCategoryId) { created in unknownCode = nil product = created } } label: { Label("Artikel anlegen", systemImage: "plus") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) } } header: { Text("Zum Scan") } } // MARK: - Logik private func search() async { let q = query.trimmingCharacters(in: .whitespaces) guard q.count >= 2 else { results = []; searching = false; return } // Kurze Verzögerung, damit nicht bei jedem Tastendruck abgefragt wird. try? await Task.sleep(nanoseconds: 250_000_000) if Task.isCancelled { return } searching = true defer { searching = false } results = (try? await APIClient.shared.searchProducts(q)) ?? [] } private func resolve(_ code: String) async { paused = true error = nil status = nil suggestion = nil unknownCode = nil do { // Einzelstück-QR (…/i/) → direkt das Stück öffnen statt Barcode-Suche. if let r = code.range(of: "/i/") { let uid = String(code[r.upperBound...]) .trimmingCharacters(in: CharacterSet(charactersIn: "/ ")) .uppercased() if !uid.isEmpty, let item = try? await APIClient.shared.itemByUid(uid: uid) { scannedItem = item scanShown = false return } } let result = try await APIClient.shared.lookup(barcode: code) suggestedGroupId = result.groupId suggestedGroupName = result.groupName suggestedCategoryId = result.categoryId suggestedCategoryName = result.categoryName if let existing = result.existingProduct { product = existing } else if let hint = result.suggestion { suggestion = hint } else { unknownCode = code } scanShown = false } catch { self.error = error.localizedDescription scanShown = false } } }