Files
Vorrania/ios/Sources/ProductDetailView.swift
Scarriffle dcee7cab6c iOS: Shop nur am Nicht-Einzelstück; QR-Fehler nennt den Server
- „Gekauft bei" (Shop) gehoert bei Einzelstuecken zum einzelnen Stueck, nicht
  ans Modell. Wie im Web wird der Shop-Picker am Produkt jetzt ausgeblendet,
  sobald „Einzelstuecke" aktiv ist; der Produktlink (beschreibt das Produkt
  allgemein) bleibt. Beim Speichern wird fuer Einzelstuecke kein Shop ans
  Modell geschrieben.
- Wird ein gescanntes Einzelstueck nicht gefunden, nennt die Meldung jetzt den
  abgefragten Server und den Original-Fehler. So ist sofort erkennbar, ob der
  aktive Server ein anderer ist als der im QR-Code (haeufigste Ursache bei
  korrekter UID) oder ob ein Netz-/Serverfehler vorliegt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 11:15:38 +02:00

402 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 && current.isIndividual {
ProductItemsSection(product: current, onChanged: { await reload() })
} else 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,
tracking: current.tracking)
} footer: {
Text(current.isObject
? "Nur Gegenstands-Kategorien. Bestimmt die eigenen Felder."
: "Nur Lebensmittel-Kategorien.")
}
if current.isObject {
// 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)
}
}
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<String> {
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,
// Einzelstücke tragen den Shop je Stück nichts ans Modell.
shopId: current.isIndividual ? nil : 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
}
}
}