import SwiftUI /// Produkt ansehen und ändern, dazu die Chargen korrigieren oder löschen. struct ProductDetailView: View { let product: Product @EnvironmentObject private var display: DisplaySettings @Environment(\.dismiss) private var dismiss @State private var current: Product @State private var showLeaveConfirm = false @State private var lots: [Lot] = [] @State private var busy = false @State private var error: String? @State private var status: String? // Bearbeitbare Felder @State private var name = "" @State private var brand = "" @State private var packageSize = "" @State private var packageLabel = "" @State private var datePrecision = "day" // Zweiteinheit („3 Stück ≙ 250 g") – Bruecke zur anderen Art. @State private var zweitAnzahl = "" @State private var zweitMenge = "" @State private var zweitBasis = "" @State private var groupId: Int? @State private var categoryId: Int? // Verwaltungsart eines Gegenstands: count | individual | bulk – umstellbar. @State private var objMode = "count" @State private var groups: [GroupItem] = [] @State private var categories: [CategoryItem] = [] // Gegenstände (Non-Food): Bezugsquelle, Link, Lagerorte und eigene Felder. @State private var shopId: Int? @State private var productUrl = "" // Gesamt-Mindestbestand (nur Gegenstände; Menge in Stück/Artikeleinheit). @State private var shops: [ShopItem] = [] @State private var locations: [StorageLocation] = [] @State private var fieldDefs: [FieldDefinition] = [] @State private var fieldValues: [String: String] = [:] @State private var editLot: Lot? // Mengen-Bestand (Menge je Lagerort): Sheet + Entnahme-Statistik werden hier // gehalten und auf Form-Ebene präsentiert, damit das Sheet stabil aufgeht. @State private var objectSheet: ObjectStockSheet? @State private var objectRemovals: RemovalSummary? private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"] init(product: Product) { self.product = product _current = State(initialValue: product) } /// Ungespeicherte Änderungen an den Artikel-Stammdaten? (Foto und Lagerort- /// Bedarf speichern sofort und zählen hier nicht.) private var isDirty: Bool { if name != current.name { return true } if brand != (current.brand ?? "") { return true } if categoryId != current.categoryId { return true } // Gruppe gibt es für alles außer Einzelstücke. if !current.isIndividual, groupId != current.groupId { return true } // Packung/MHD nur bei Lebensmitteln und Verbrauchsgegenständen. if current.foodLike { if packageSize != (current.packageSize.map { formatAmount($0) } ?? "") { return true } if packageLabel != (current.packageLabel ?? "") { return true } if datePrecision != (current.datePrecision == "month" ? "month" : "day") { return true } if zweitBasis != (current.secondaryBase ?? "") { return true } if zweitAnzahl != (current.secondaryCount.map { formatAmount($0) } ?? "") { return true } if zweitMenge != (current.secondaryAmount.map { formatAmount($0) } ?? "") { return true } } if current.isObject { if objMode != currentObjMode { return true } if !current.isIndividual && !current.isBulk, shopId != current.shopId { return true } if !current.isBulk, productUrl != (current.productUrl ?? "") { return true } if fieldValues != Self.stringValues(current.fieldValues) { return true } } return false } private func attemptLeave() { if isDirty { showLeaveConfirm = true } else { dismiss() } } var body: some View { Form { if let status { Section { Text(status).foregroundStyle(.green).font(.callout) } } if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } ProductPhotoSection(productId: current.id) if current.isObject && current.isIndividual { ProductItemsSection(product: current, onChanged: { await reload() }, onFinish: { dismiss() }) } else if current.isObject && !current.isBulk { // Menge je Lagerort (Verbrauchsgegenstände laufen als Charge, s.u.). ObjectStockSection(product: current, locations: locations, lots: lots, removals: objectRemovals, sheet: $objectSheet) } else { // Lebensmittel und Verbrauchsgegenstände: Vorrat aus den Chargen. Section("Bestand") { // Mit Zweiteinheit beide Lesarten: „6 Stück · 500 g". LabeledContent("Vorrat", value: { let haupt = "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)" guard let zweit = current.stockInZweit else { return haupt } return "\(haupt) · \(formatAmount(zweit)) \(current.zweitKurz)" }()) if current.expiredCount > 0 { LabeledContent("Abgelaufen", value: "\(current.expiredCount)") .foregroundStyle(.red) } } } // Beschriftungen links, Werte rechts: Ohne sie las man nur noch // "Bratbutter" / "M-Classic" / "450" ohne jeden Zusammenhang. Section("Artikel") { LabeledField(label: "Name", text: $name) LabeledField(label: "Marke", text: $brand) if current.foodLike { QuantityField(label: "Packungsgröße", text: $packageSize, suffix: display.baseUnitLabel(current.baseUnit)) Picker("Bezeichnung", selection: $packageLabel) { ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) } } Picker("MHD-Angabe", selection: $datePrecision) { Text("Tagesdatum").tag("day") Text("nur Monat/Jahr").tag("month") } } } // Zweiteinheit: dieselbe Ware in der ANDEREN Art lesbar machen. // Damit zaehlt der Artikel auch in Gruppen der anderen Art und // laesst sich darin ein- und auslagern. Das Gebinde bleibt frei. if current.foodLike { Section { QuantityField(label: "Menge", text: $zweitAnzahl, suffix: display.baseUnitLabel(current.baseUnit)) Picker("entspricht", selection: $zweitBasis) { Text("– keine –").tag("") ForEach(zweitBasisOptionen, id: \.self) { basis in Text(display.baseUnitLabel(basis)).tag(basis) } } if !zweitBasis.isEmpty { QuantityField(label: "das sind", text: $zweitMenge, suffix: display.baseUnitLabel(zweitBasis)) } } header: { Text("Zweiteinheit (optional)") } footer: { Text(zweitHinweis) } } Section { CategoryPicker(categories: categories, selection: $categoryId, tracking: current.tracking) } footer: { Text(current.isObject ? "Nur Gegenstands-Kategorien. Bestimmt die eigenen Felder." : "Nur Lebensmittel-Kategorien.") } // Verwaltungsart eines Gegenstands umstellen (z.B. Menge je Lagerort → // Einzelstücke). Wirkt nach dem Speichern; der Bestand wird nicht // automatisch umgerechnet. if current.isObject { Section { Picker("Verwaltung", selection: $objMode) { Text("Menge je Lagerort").tag("count") Text("Einzelstücke").tag("individual") Text("Verbrauchsgegenstand").tag("bulk") } } footer: { Text(objMode == currentObjMode ? "Wie der Bestand geführt wird." : "Wird beim Speichern umgestellt – vorhandener Bestand wird nicht umgerechnet.") } } // Bezugsquelle/Link nur für Menge je Lagerort und Einzelstücke – // Verbrauchsgegenstände laufen wie Lebensmittel und lassen das weg. if current.isObject && !current.isBulk { // „Gekauft bei" gehört bei Einzelstücken zum einzelnen Stück // (jedes kann woanders gekauft sein), nicht ans Modell – wie im // Web wird der Shop hier dann ausgeblendet. Der Produktlink // beschreibt das Produkt allgemein und bleibt. Section(current.isIndividual ? "Produktlink" : "Gekauft bei") { if !current.isIndividual { Picker("Shop", selection: $shopId) { Text("– unbekannt / mehrere –").tag(Int?.none) ForEach(shops) { shop in Text(shop.name).tag(Int?.some(shop.id)) } } } LabeledField(label: "Produktlink", text: $productUrl) if !productUrl.isEmpty, let url = URL(string: productUrl) { Link("Im Onlineshop öffnen", destination: url) } } } // Gruppe für alles außer Einzelstücke (Lebensmittel, Verbrauchs- // gegenstand, Menge je Lagerort). if !current.isIndividual { Section { Picker("Gruppe", selection: $groupId) { Text("– keine –").tag(Int?.none) ForEach(groups) { gruppe in Text(gruppe.name).tag(Int?.some(gruppe.id)) } } } footer: { Text("Gruppe: zählt Bestände mehrerer Artikel zusammen. Der EAN-Code " + "wandert bei einem Wechsel mit.") } } // Eigene Felder für alle Gegenstände mit Kategorie-Feldern. if current.isObject && !fieldDefs.isEmpty { Section("Eigene Felder") { ForEach(fieldDefs) { def in ObjectFieldRow(def: def, value: fieldBinding(def)) } } } // Mindestbestand bei Lebensmitteln UND Verbrauchsgegenständen (Charge). // Menge je Lagerort und Einzelstücke haben keinen Mindestbestand. // // Es gibt kein separates „Gesamt"-Feld mehr: der Bedarf hängt immer // an einem Ort, und „Überall" ist einer davon (der oberste). if current.foodLike { Section { NavigationLink { ProductLocationMinView(product: current) { await reload() } } label: { let zeilen = current.locationMinStocks ?? [] let ueberall = zeilen.first { $0.locationId == nil } Label { VStack(alignment: .leading, spacing: 2) { Text("Mindestbestand") Text(minStockUntertitel(zeilen: zeilen, ueberall: ueberall)) .font(.caption).foregroundStyle(.secondary) } } icon: { Image(systemName: "mappin.and.ellipse") } } } footer: { Text("„Überall“ heißt: egal wo, Hauptsache im Haus – Käufe für einen " + "Lagerort decken das mit ab.") } } Section("Erkennung") { LabeledContent("Barcode", value: current.barcode ?? "–") if !current.barcodes.isEmpty { LabeledContent("Weitere Codes", value: "\(current.barcodes.count)") } } Section { Button(busy ? "Speichern…" : "Änderungen speichern") { Task { await save() } } .disabled(busy || name.isEmpty) } if current.foodLike { Section("Chargen") { ForEach(lots) { lot in Button { editLot = lot } label: { HStack { VStack(alignment: .leading, spacing: 2) { Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))") // Ohne den Lagerort sehen zwei Chargen mit // gleichem MHD identisch aus – er ist hier // oft das einzige Unterscheidungsmerkmal. Text("\(formatAmount(lot.quantity / current.articleUnitFactor)) \(current.articleUnitLabel) · \(lotLocationLabel(lot))") .font(.caption).foregroundStyle(.secondary) } Spacer() Image(systemName: "chevron.right").foregroundStyle(.tertiary) } } .foregroundStyle(.primary) } .onDelete { indexSet in Task { await deleteLots(at: indexSet) } } if lots.isEmpty { Text("Keine Chargen im Bestand.").foregroundStyle(.secondary) } } } Section { // Derselbe Verlauf wie im Listen-Tab, nur auf diesen Artikel // gefiltert. NavigationLink { HistoryView(productId: current.id) } label: { Label("Verlauf dieses Artikels", systemImage: "clock.arrow.circlepath") } } } .navigationTitle(current.name) .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(true) .toolbar { ToolbarItem(placement: .topBarLeading) { Button { attemptLeave() } label: { Label("Zurück", systemImage: "chevron.left") } } } .confirmationDialog("Ungespeicherte Änderungen", isPresented: $showLeaveConfirm, titleVisibility: .visible) { Button("Speichern") { Task { await save(); dismiss() } } Button("Verwerfen", role: .destructive) { dismiss() } Button("Abbrechen", role: .cancel) {} } message: { Text("Änderungen an Name/Marke/Kategorie sind noch nicht gespeichert. " + "Foto und Lagerort-Bedarf werden sofort gespeichert.") } .sheet(item: $editLot) { lot in NavigationStack { LotEditView(lot: lot, product: current, locations: locations) { Task { await reload() } } } } // Mengen-Sheets auf der stabilen Form-Ebene (nicht im Abschnitt), sonst // schloss sich das Fenster beim ersten Öffnen sofort wieder. .sheet(item: $objectSheet) { welche in NavigationStack { switch welche { case .add: ObjectAddSheet(product: current, locations: locations, einheit: current.unitName.isEmpty ? "Stück" : current.unitName, perform: { await reload() }) case .relocate(let from): ObjectRelocateSheet(product: current, locations: locations, initialFrom: from, perform: { await reload() }) case .remove(let loc): ObjectRemoveSheet(product: current, locations: locations, einheit: current.unitName.isEmpty ? "Stück" : current.unitName, initialLoc: loc, perform: { await reload() }) } } } .task { fillForm() await reload() } .onChange(of: categoryId) { newId in // Bei Wechsel auf eine Gegenstands-Kategorie deren Felder nachladen. Task { if let cid = newId, categories.first(where: { $0.id == cid })?.isObject == true { fieldDefs = (try? await APIClient.shared.categoryFields(categoryId: cid)) ?? [] } else { fieldDefs = [] } } } } /// Kurzfassung der hinterlegten Mindestbestände für die Übersichtszeile. private func minStockUntertitel(zeilen: [LocationMinStock], ueberall: LocationMinStock?) -> String { if zeilen.isEmpty { return "keiner hinterlegt" } let faktor = max(current.unitFactor, 1) var teile: [String] = [] if let u = ueberall { teile.append("Überall \(formatAmount(u.minStock / faktor)) \(current.unitName)") } let orte = zeilen.count - (ueberall == nil ? 0 : 1) if orte > 0 { teile.append("\(orte) \(orte == 1 ? "Lagerort" : "Lagerorte")") } return teile.joined(separator: " · ") } /// Basiseinheiten der ANDEREN Arten – eine Bruecke auf die eigene Art /// haette nichts umzurechnen. private var zweitBasisOptionen: [String] { ["piece", "gram", "milliliter"].filter { $0 != current.baseUnit } } /// Kontrolle unter der Eingabe: „1 Stück ≈ 83,33 g". private var zweitHinweis: String { let n = Double(zweitAnzahl.replacingOccurrences(of: ",", with: ".")) ?? 0 let m = Double(zweitMenge.replacingOccurrences(of: ",", with: ".")) ?? 0 guard !zweitBasis.isEmpty, n > 0, m > 0 else { return "Zusätzliche Lesart desselben Artikels – damit zählt er auch in " + "Gruppen der anderen Art und lässt sich darin ein- und auslagern." } return "1 \(display.baseUnitLabel(current.baseUnit)) ≈ \(formatAmount(m / n)) " + "\(display.baseUnitLabel(zweitBasis)). Bestände bleiben beim Ändern " + "unverändert – nur ihre Umrechnung verschiebt sich." } /// Aktuelle Verwaltungsart des geladenen Artikels. private var currentObjMode: String { current.isIndividual ? "individual" : (current.isBulk ? "bulk" : "count") } private func fillForm() { name = current.name brand = current.brand ?? "" packageSize = current.packageSize.map { formatAmount($0) } ?? "" packageLabel = current.packageLabel ?? "" datePrecision = current.datePrecision == "month" ? "month" : "day" zweitBasis = current.secondaryBase ?? "" zweitAnzahl = current.secondaryCount.map { formatAmount($0) } ?? "" zweitMenge = current.secondaryAmount.map { formatAmount($0) } ?? "" groupId = current.groupId categoryId = current.categoryId shopId = current.shopId productUrl = current.productUrl ?? "" objMode = currentObjMode fieldValues = Self.stringValues(current.fieldValues) } private static func stringValues(_ dict: [String: String?]?) -> [String: String] { guard let dict else { return [:] } var out: [String: String] = [:] for (schluessel, wert) in dict { if let wert { out[schluessel] = wert } } return out } private func fieldBinding(_ def: FieldDefinition) -> Binding { let key = String(def.id) return Binding(get: { fieldValues[key] ?? "" }, set: { fieldValues[key] = $0 }) } /// Lagerort einer Charge als Pfad („Keller → Regal 2"). Chargen ohne Ort – /// und solche, deren Ort (noch) nicht geladen ist – bleiben „ohne Ort". private func lotLocationLabel(_ lot: Lot) -> String { guard let id = lot.locationId, let ort = locations.first(where: { $0.id == id }) else { return "ohne Ort" } return ort.path(in: locations) } private func reload() async { groups = (try? await APIClient.shared.groups()) ?? [] categories = (try? await APIClient.shared.categories()) ?? [] lots = (try? await APIClient.shared.lots(productId: current.id)) ?? [] shops = (try? await APIClient.shared.shops()) ?? [] locations = (try? await APIClient.shared.locations()) ?? [] if let frisch = try? await APIClient.shared.product(id: current.id) { current = frisch } if current.isObject, let cid = current.categoryId { fieldDefs = (try? await APIClient.shared.categoryFields(categoryId: cid)) ?? [] } else { fieldDefs = [] } // Entnahme-Statistik nur für „Menge je Lagerort"-Gegenstände. if current.isObject && !current.isBulk && !current.isIndividual { objectRemovals = try? await APIClient.shared.productRemovals(id: current.id) } else { objectRemovals = nil } } /// Verwaltungsart (individual/bulk) mitschicken, wenn sie umgestellt wurde. private func applyModeChange(_ req: inout ProductUpdateRequest) { guard current.isObject, objMode != currentObjMode else { return } req.individual = objMode == "individual" req.bulk = objMode == "bulk" req.sendMode = true } private func save() async { error = nil status = nil busy = true defer { busy = false } do { if current.foodLike { // Lebensmittel ODER Verbrauchsgegenstand: als Charge mit Einheit/ // Packung/MHD/Gruppe. Verbrauchsgegenstände (isObject) tragen // zusätzlich eigene Felder und einen Mindestbestand. var req = ProductUpdateRequest( name: name, brand: brand.isEmpty ? nil : brand, packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")), packageLabel: packageLabel.isEmpty ? nil : packageLabel, datePrecision: datePrecision, groupId: groupId, categoryId: categoryId ) // Der Mindestbestand wird in ProductLocationMinView gepflegt und // hier bewusst NICHT mitgeschickt – sonst überschriebe das // Stammdaten-Speichern die dort gesetzte „Überall"-Zeile. // // Zweiteinheit nur vollstaendig; leere oder halbe Angabe loescht // die Bruecke (secondary_base null raeumt im Backend mit auf). let zn = Double(zweitAnzahl.replacingOccurrences(of: ",", with: ".")) let zm = Double(zweitMenge.replacingOccurrences(of: ",", with: ".")) req.sendSecondary = true if !zweitBasis.isEmpty, let zn, let zm, zn > 0, zm > 0 { req.secondaryBase = zweitBasis req.secondaryCount = zn req.secondaryAmount = zm } if current.isObject { var fv: [String: String?] = [:] for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" } req.fieldValues = fv } applyModeChange(&req) current = try await APIClient.shared.updateProduct(id: current.id, req) if current.isObject { fieldValues = Self.stringValues(current.fieldValues) } } else { // Gegenstand: Menge je Lagerort oder Einzelstücke. Kein // Mindestbestand (Alt-Werte werden geleert); Menge-Gegenstände // dürfen weiter in eine Gruppe. var fv: [String: String?] = [:] for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" } var req = ProductUpdateRequest( name: name, brand: brand.isEmpty ? nil : brand, packageSize: nil, packageLabel: nil, datePrecision: nil, groupId: current.isIndividual ? nil : groupId, categoryId: categoryId, // Einzelstücke tragen den Shop je Stück – nichts ans Modell. shopId: current.isIndividual ? nil : shopId, productUrl: productUrl.isEmpty ? nil : productUrl, fieldValues: fv, minStock: nil, minStockInPackages: false, minStockUnitId: nil, sendMinStock: true ) applyModeChange(&req) current = try await APIClient.shared.updateProduct(id: current.id, req) fieldValues = Self.stringValues(current.fieldValues) } status = "Gespeichert." } catch { self.error = error.localizedDescription } } private func deleteLots(at indexSet: IndexSet) async { error = nil for index in indexSet { do { try await APIClient.shared.deleteLot(id: lots[index].id) } catch { self.error = error.localizedDescription } } await reload() } } /// Lädt ein Produkt anhand seiner ID nach und zeigt dann die Detailansicht – /// z.B. beim Antippen einer Bewegung im Verlauf oder auf der Startseite. struct ProductByIdView: View { let productId: Int @State private var product: Product? @State private var error: String? var body: some View { Group { if let product { ProductDetailView(product: product) } else if let error { VStack(spacing: 12) { Text(error).foregroundStyle(.red).font(.callout) .multilineTextAlignment(.center) Button("Erneut versuchen") { Task { await load() } } } .padding() } else { ProgressView() } } .task { if product == nil { await load() } } } private func load() async { do { product = try await APIClient.shared.product(id: productId) error = nil } catch { self.error = error.localizedDescription } } } /// Menge, MHD und Lagerort einer Charge korrigieren. struct LotEditView: View { let lot: Lot let product: Product let locations: [StorageLocation] var onSaved: () -> Void @Environment(\.dismiss) private var dismiss @State private var quantity = "" @State private var hasDate = false @State private var bestBefore = Date() @State private var precision = "day" @State private var locationId: String? @State private var locScanShown = false @State private var busy = false @State private var error: String? var body: some View { Form { Section("Menge") { QuantityField(label: "Menge", text: $quantity, suffix: product.articleUnitLabel) } Section("MHD") { Toggle("MHD angeben", isOn: $hasDate) if hasDate { Picker("Angabe", selection: $precision) { Text("Tagesdatum").tag("day") Text("nur Monat/Jahr").tag("month") } if precision == "month" { MonthYearPicker(date: $bestBefore) } else { DatePicker("MHD", selection: $bestBefore, displayedComponents: .date) } } } if !locations.isEmpty { Section("Lagerort") { Picker("Lagerort", selection: $locationId) { Text("– ohne –").tag(String?.none) ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) } } // Ort per QR am Regal/Fach setzen, statt in der Liste zu suchen. Button { locScanShown = true } label: { Label("Lagerort scannen", systemImage: "qrcode.viewfinder") } } } if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } Section { Button(busy ? "Speichern…" : "Speichern") { Task { await save() } } .disabled(busy) } } .navigationTitle("Charge ändern") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } } .fullScreenCover(isPresented: $locScanShown) { LocationScannerView(locations: locations) { ort in locationId = ort.id } .ignoresSafeArea() } .onAppear(perform: fill) } private func fill() { quantity = formatAmount(lot.quantity / product.articleUnitFactor) locationId = lot.locationId precision = lot.bestBeforePrecision == "month" ? "month" : "day" if let raw = lot.bestBefore { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" if let datum = formatter.date(from: raw) { bestBefore = datum hasDate = true } } } private func save() async { error = nil guard let menge = Double(quantity.replacingOccurrences(of: ",", with: ".")), menge > 0 else { error = "Bitte eine Menge größer 0 angeben." return } busy = true defer { busy = false } let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" let datum = precision == "month" ? bestBefore.startOfMonth : bestBefore do { // Das Backend rechnet Chargenmengen in Basiseinheiten. _ = try await APIClient.shared.updateLot( id: lot.id, LotUpdateRequest( quantity: menge * product.articleUnitFactor, bestBefore: hasDate ? formatter.string(from: datum) : nil, bestBeforePrecision: precision, locationId: locationId ) ) onSaved() dismiss() } catch { self.error = error.localizedDescription } } } // MARK: - Mindestbestand je Lagerort /// Bedarf eines Produkts je Lagerort bearbeiten (zusätzlich zum globalen /// Mindestbestand). Menge in Artikeleinheiten. struct ProductLocationMinView: View { let product: Product var onChanged: (() async -> Void)? = nil @Environment(\.dismiss) private var dismiss private struct MinRow: Identifiable { let id = UUID() var locationId: String? var amount: String } @State private var locations: [StorageLocation] = [] @State private var rows: [MinRow] = [] @State private var busy = false @State private var error: String? // Gespeichert wird in Basiseinheiten, erfasst in der Anzeigeeinheit (g/ml/Stück). private var dispFaktor: Double { product.unitFactor == 0 ? 1 : product.unitFactor } var body: some View { Form { Section { Text("Was soll wo immer da sein? „Überall“ heißt: egal wo, Hauptsache " + "im Haus – Käufe für einen Lagerort decken das mit ab. " + "Menge in \(product.unitName).") .font(.caption).foregroundStyle(.secondary) } ForEach($rows) { $row in HStack { Picker("Ort", selection: $row.locationId) { Text(UEBERALL_NAME).tag(String?.some(UEBERALL_ID)) ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) } } TextField("Menge", text: $row.amount) .keyboardType(.decimalPad) .multilineTextAlignment(.trailing) .frame(width: 70) Button(role: .destructive) { rows.removeAll { $0.id == row.id } } label: { Image(systemName: "trash") } .buttonStyle(.borderless) } } Button { rows.append(MinRow(locationId: UEBERALL_ID, amount: "")) } label: { Label("Mindestbestand hinzufügen", systemImage: "plus") } if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } } .navigationTitle("Mindestbestand") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button(busy ? "Speichern…" : "Speichern") { Task { await save() } } .disabled(busy) } } .task { await load() } } private func load() async { locations = (try? await APIClient.shared.locations()) ?? [] // „Überall" ist der Ort nil – im Picker braucht es einen Platzhalter. rows = (product.locationMinStocks ?? []).map { MinRow(locationId: $0.locationId ?? UEBERALL_ID, amount: formatAmount($0.minStock / dispFaktor)) } } private func save() async { busy = true defer { busy = false } var list: [LocationMinStockIn] = [] var gesehen: Set = [] for row in rows { guard let loc = row.locationId, !gesehen.contains(loc) else { continue } let wert = Double(row.amount.replacingOccurrences(of: ",", with: ".")) ?? 0 if wert > 0 { gesehen.insert(loc) // Eingabe in Anzeigeeinheit → Basiseinheiten (so gespeichert). list.append(LocationMinStockIn( locationId: loc == UEBERALL_ID ? nil : loc, minStock: wert * dispFaktor)) } } do { _ = try await APIClient.shared.setProductLocationMinStock(id: product.id, list) await onChanged?() dismiss() } catch { self.error = error.localizedDescription } } }