import SwiftUI import PhotosUI /// Artikel per Namenssuche auswählen (Alternative zum Scannen). struct ProductSearchView: View { var onPick: (Product) -> Void @Environment(\.dismiss) private var dismiss @State private var query = "" @State private var results: [Product] = [] @State private var busy = false var body: some View { List { ForEach(results) { product in Button { onPick(product) dismiss() } label: { VStack(alignment: .leading, spacing: 2) { Text(product.name) Text("\(formatted(product.stockInArticleUnits)) \(product.articleUnitLabel)") .font(.caption).foregroundStyle(.secondary) } } } if results.isEmpty && !busy { Text("Keine Treffer").foregroundStyle(.secondary) } } .searchable(text: $query, prompt: "Artikel suchen") .onSubmit(of: .search) { Task { await search() } } .task { await search() } .onChange(of: query) { _ in Task { await search() } } .navigationTitle("Artikel suchen") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } } } private func formatted(_ value: Double) -> String { value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value) } private func search() async { busy = true defer { busy = false } results = (try? await APIClient.shared.searchProducts(query)) ?? [] } } /// Neues Produkt anlegen – bei Bedarf mit den Daten von Open Food Facts vorbefüllt. struct ProductFormView: View { let prefillBarcode: String? let groupId: Int? /// Aus der Open-Food-Facts-Einordnung vorgeschlagene Kategorie. var categoryId: Int? var suggestion: LookupResult.Suggestion? /// Vorbelegter Typ ("food"/"object"), z.B. aus der Lebensmittel- oder /// Gegenstände-Liste heraus. Ein Kategorie-Vorschlag hat weiter Vorrang. var initialType: String? var onCreated: (Product) -> Void @Environment(\.dismiss) private var dismiss @EnvironmentObject private var session: Session @State private var barcode = "" @State private var name = "" @State private var brand = "" @State private var baseUnit = "piece" @State private var packageSize = "" @State private var packageLabel = "" @State private var datePrecision = "day" @State private var selectedGroupId: Int? @State private var selectedCategoryId: Int? // Umschalter Lebensmittel/Gegenstand: filtert die Kategorieauswahl. @State private var modus = "object" // Verwaltungsart eines Gegenstands: "count" (Menge je Lagerort), // "individual" (Einzelstücke) oder "bulk" (Verbrauchsgegenstand wie Lebensmittel). @State private var objMode = "count" @State private var groups: [GroupItem] = [] @State private var categories: [CategoryItem] = [] @State private var units: [Unit] = [] @State private var newGroupShown = false @State private var busy = false @State private var error: String? // Foto schon beim Anlegen: erst nach dem Anlegen hochladen (ID nötig). @State private var pendingImage: UIImage? @State private var showCamera = false @State private var pickerItem: PhotosPickerItem? // Eigene Felder der (Gegenstands-)Kategorie – schon beim Anlegen ausfüllbar. @State private var fieldDefs: [FieldDefinition] = [] @State private var fieldValues: [String: String] = [:] // Text-Scan für Name/Marke (statt abtippen), wenn ein Produkt neu ist. @State private var scanTarget: TextScanTarget? enum TextScanTarget: Identifiable { case name, brand var id: Int { self == .name ? 0 : 1 } var titel: String { self == .name ? "Name scannen" : "Marke scannen" } } private let baseUnits = [("piece", "Stück"), ("gram", "Gramm"), ("milliliter", "Milliliter")] // Beschriftungen kommen sonst aus DisplaySettings.baseUnitLabel. private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"] /// Nutzt die Lebensmittel-Pfade (Einheit, MHD, Charge): Lebensmittel ODER /// Verbrauchsgegenstand. private var foodLike: Bool { modus == "food" || objMode == "bulk" } /// Gruppe anbieten: Lebensmittel, Verbrauchsgegenstand oder Menge je Lagerort /// (nicht bei Einzelstücken). private var showGroup: Bool { modus == "food" || (modus == "object" && objMode != "individual") } var body: some View { Form { Section("Artikel") { LabeledField(label: "Barcode", text: $barcode, keyboard: .numberPad) LabeledField(label: "Name", text: $name) if TextScanView.isSupported { Button { scanTarget = .name } label: { Label("Name scannen", systemImage: "text.viewfinder") } } LabeledField(label: "Marke", text: $brand) if TextScanView.isSupported { Button { scanTarget = .brand } label: { Label("Marke scannen", systemImage: "text.viewfinder") } } } Section { Picker("Typ", selection: $modus) { Text("Lebensmittel").tag("food") Text("Gegenstand").tag("object") } .pickerStyle(.segmented) CategoryPicker(categories: categories, selection: $selectedCategoryId, tracking: modus) if modus == "object" { Picker("Verwaltung", selection: $objMode) { Text("Menge je Lagerort").tag("count") Text("Einzelstücke").tag("individual") Text("Verbrauchsgegenstand").tag("bulk") } } } footer: { Text(modus == "object" ? (objMode == "individual" ? "Einzelstücke: jedes Stück mit eigener UID/QR, Kaufdatum, Garantie." : objMode == "bulk" ? "Verbrauchsgegenstand: wie ein Lebensmittel als Charge mit Menge und Einheit (z.B. Sonnencreme in ml) – nur ohne eindeutigen Code." : "Gegenstand: Menge je Lagerort und eigene Felder.") : "Lebensmittel: Chargen mit Mindesthaltbarkeit.") } .onChange(of: modus) { neu in // Passt die gewählte Kategorie nicht mehr zum Typ, Auswahl leeren. if let cid = selectedCategoryId, categories.first(where: { $0.id == cid })?.tracking != neu { selectedCategoryId = nil } } // Gruppe: Lebensmittel, Verbrauchsgegenstand und Menge je Lagerort // (nur Einzelstücke bleiben außen vor). if showGroup { Section { Picker("Gruppe", selection: $selectedGroupId) { Text("– keine –").tag(Int?.none) ForEach(groups) { gruppe in Text(gruppe.name).tag(Int?.some(gruppe.id)) } } if session.isAdmin { Button { newGroupShown = true } label: { Label("Neue Gruppe anlegen", systemImage: "plus") } } } footer: { Text("Gruppe: zählt Bestände mehrerer Artikel zusammen. Der EAN-Code " + "dieses Artikels erscheint danach automatisch bei der Gruppe.") } } // Einheit + MHD nur für Lebensmittel und Verbrauchsgegenstände (Charge). if foodLike { Section("Einheit") { Picker("Basiseinheit", selection: $baseUnit) { ForEach(baseUnits, id: \.0) { Text($0.1).tag($0.0) } } QuantityField(label: "Packungsgröße", text: $packageSize, suffix: DisplaySettings.baseUnitLabel(baseUnit)) Picker("Bezeichnung", selection: $packageLabel) { ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) } } } Section("MHD") { Picker("Angabe", selection: $datePrecision) { Text("Tagesdatum").tag("day") Text("nur Monat/Jahr").tag("month") } } } // Eigene Felder der Gegenstands-Kategorie (z.B. Größe, Farbe beim // T-Shirt) – schon beim Anlegen ausfüllbar, nicht erst danach. if !fieldDefs.isEmpty { Section("Eigene Felder") { ForEach(fieldDefs) { def in ObjectFieldRow(def: def, value: fieldBinding(def)) } } } // Foto direkt beim Anlegen – z.B. wenn Open Food/Products Facts keins // liefert. Wird nach dem Anlegen hochgeladen (dann gibt es eine ID). Section("Foto") { if let pendingImage { Image(uiImage: pendingImage) .resizable().scaledToFit() .frame(maxWidth: .infinity).frame(maxHeight: 200) .clipShape(RoundedRectangle(cornerRadius: 8)) } Button { showCamera = true } label: { Label("Foto machen", systemImage: "camera") } PhotosPicker(selection: $pickerItem, matching: .images) { Label("Aus Galerie", systemImage: "photo.on.rectangle") } if pendingImage != nil { Button(role: .destructive) { pendingImage = nil } label: { Label("Foto entfernen", systemImage: "trash") } } } if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } Section { Button(busy ? "Anlegen…" : "Anlegen & weiter") { Task { await create() } } .disabled(busy || name.isEmpty) } } .navigationTitle("Neuer Artikel") .navigationBarTitleDisplayMode(.inline) .fullScreenCover(isPresented: $showCamera) { CameraPicker(isPresented: $showCamera) { image in pendingImage = image } .ignoresSafeArea() } .fullScreenCover(item: $scanTarget) { target in NavigationStack { TextScanView(titel: target.titel) { text in if target == .name { name = text } else { brand = text } } } } .onChange(of: pickerItem) { item in guard let item else { return } Task { if let data = try? await item.loadTransferable(type: Data.self), let ui = UIImage(data: data) { pendingImage = ui } pickerItem = nil } } .onChange(of: selectedCategoryId) { cid in // Bei einer Gegenstands-Kategorie deren (vererbte) Felder nachladen. Task { if let cid, categories.first(where: { $0.id == cid })?.isObject == true { fieldDefs = (try? await APIClient.shared.categoryFields(categoryId: cid)) ?? [] } else { fieldDefs = [] } } } .onAppear(perform: prefill) .task { groups = (try? await APIClient.shared.groups()) ?? [] categories = (try? await APIClient.shared.categories()) ?? [] units = (try? await APIClient.shared.units()) ?? [] // Kam eine Kategorie aus dem Barcode-Vorschlag, den Typ dazu setzen. if let cid = selectedCategoryId, let t = categories.first(where: { $0.id == cid })?.tracking { modus = t } } .sheet(isPresented: $newGroupShown) { NavigationStack { NewGroupSheet(units: units) { created in groups.append(created) selectedGroupId = created.id } } } } private func fieldBinding(_ def: FieldDefinition) -> Binding { let key = String(def.id) return Binding(get: { fieldValues[key] ?? "" }, set: { fieldValues[key] = $0 }) } /// Werte der geltenden eigenen Felder als {feld_id: Wert} – nil, wenn keine. private func eigeneFeldwerte() -> [String: String?]? { guard !fieldDefs.isEmpty else { return nil } var fv: [String: String?] = [:] for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" } return fv } private func prefill() { if let initialType { modus = initialType } barcode = suggestion?.barcode ?? prefillBarcode ?? "" // Aus dem gescannten Code vorgeschlagene Gruppe und Kategorie // uebernehmen. Beides bleibt aenderbar - gespeichert wird erst mit // "Anlegen & weiter". selectedGroupId = groupId selectedCategoryId = categoryId if let suggestion { name = suggestion.name brand = suggestion.brand ?? "" baseUnit = suggestion.baseUnit ?? "piece" if let size = suggestion.packageSize { packageSize = String(size) } } } private func create() async { error = nil busy = true defer { busy = false } do { let product = try await APIClient.shared.createProduct( NewProductRequest( barcode: barcode.isEmpty ? nil : barcode, name: name, brand: brand.isEmpty ? nil : brand, imageUrl: suggestion?.imageUrl, baseUnit: baseUnit, packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")), packageLabel: packageLabel.isEmpty ? nil : packageLabel, datePrecision: datePrecision, groupId: selectedGroupId, categoryId: selectedCategoryId, individual: modus == "object" ? (objMode == "individual") : nil, bulk: modus == "object" ? (objMode == "bulk") : nil, fieldValues: eigeneFeldwerte() ) ) // Vorgemerktes Foto hochladen – ein Fehler darf das Anlegen nicht // zunichtemachen, der Artikel existiert bereits. if let pendingImage, let data = productImageJPEG(pendingImage) { _ = try? await APIClient.shared.uploadProductImage( id: product.id, data: data, contentType: "image/jpeg") } onCreated(product) dismiss() } catch { self.error = error.localizedDescription } } }