import SwiftUI /// Produkt ansehen und ändern, dazu die Chargen korrigieren oder löschen. struct ProductDetailView: View { let product: Product @EnvironmentObject private var display: DisplaySettings @State private var current: Product @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" @State private var groupId: Int? @State private var categoryId: Int? @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 = "" @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? private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"] init(product: Product) { self.product = product _current = State(initialValue: product) } 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 { ObjectStockSection(product: current, locations: locations, lots: lots, onChanged: { await reload() }) } else { Section("Bestand") { LabeledContent("Vorrat", value: "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)") 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.isObject { 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") } } } Section { CategoryPicker(categories: categories, selection: $categoryId) } footer: { Text(current.isObject ? "Kategorie bestimmt die Verwaltungsart (Gegenstand) und die eigenen Felder." : "Kategorie: nur für den Überblick in der Artikelliste.") } if current.isObject { Section("Gekauft bei") { 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) } } if !fieldDefs.isEmpty { Section("Eigene Felder") { ForEach(fieldDefs) { def in ObjectFieldRow(def: def, value: fieldBinding(def)) } } } } else { 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 Marken zusammen. Der EAN-Code " + "wandert bei einem Wechsel mit.") } } 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.isObject { 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))") Text("\(formatAmount(lot.quantity / current.articleUnitFactor)) \(current.articleUnitLabel)") .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) .sheet(item: $editLot) { lot in NavigationStack { LotEditView(lot: lot, product: current) { Task { 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 = [] } } } } private func fillForm() { name = current.name brand = current.brand ?? "" packageSize = current.packageSize.map { formatAmount($0) } ?? "" packageLabel = current.packageLabel ?? "" datePrecision = current.datePrecision == "month" ? "month" : "day" groupId = current.groupId categoryId = current.categoryId shopId = current.shopId productUrl = current.productUrl ?? "" 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 }) } 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 = [] } } private func save() async { error = nil status = nil busy = true defer { busy = false } do { if current.isObject { // Gegenstände: keine Lebensmittel-Felder, dafür Shop, Link und eigene Felder. var fv: [String: String?] = [:] for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" } current = try await APIClient.shared.updateProduct( id: current.id, ProductUpdateRequest( name: name, brand: brand.isEmpty ? nil : brand, packageSize: nil, packageLabel: nil, datePrecision: nil, groupId: nil, categoryId: categoryId, shopId: shopId, productUrl: productUrl.isEmpty ? nil : productUrl, fieldValues: fv ) ) fieldValues = Self.stringValues(current.fieldValues) } else { current = try await APIClient.shared.updateProduct( id: current.id, 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 ) ) } 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() } } /// Menge und MHD einer Charge korrigieren. struct LotEditView: View { let lot: Lot let product: Product 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 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 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() } } } .onAppear(perform: fill) } private func fill() { quantity = formatAmount(lot.quantity / product.articleUnitFactor) 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 ) ) onSaved() dismiss() } catch { self.error = error.localizedDescription } } }