Files
Vorrania/ios/Sources/ProductDetailView.swift
Scarriffle f33054c534 App: Obergruppen zuordnen, Mindestbestaende je Ort bearbeiten
Die App konnte bei Gruppen bisher nur umbenennen und Mindestbestaende lesen.
Jetzt gibt es einen vollen Gruppen-Editor (Sources/GroupViews.swift): Name,
Obergruppen als Mehrfachauswahl, Einheit und Mindestbestaende je Ort. Bewusst
eine flache Liste mit "unter: ..." statt TreeMasterView - das Protokoll TreeItem
kennt genau einen Elternteil und wuerde eine Gruppe mit zwei Obergruppen doppelt
zeigen. Die eigene Gruppe und ihre Untergruppen sind in der Auswahl
ausgeschlossen, damit kein Ring entsteht.

Mindestbestaende-Uebersicht ist nach Lagerort gegliedert ("Ueberall" zuerst) und
nicht mehr nur lesend - Gruppenzeilen oeffnen den neuen Editor. Am Artikel
entfaellt das separate Feld "Gesamt": der Bedarf haengt immer an einem Ort, und
"Ueberall" ist einer davon. Das Stammdaten-Speichern schickt min_stock nicht
mehr mit, sonst wuerde es die dort gesetzte Ueberall-Zeile ueberschreiben.

APIClient kann jetzt updateGroup und setGroupLocationMinStock. GroupUpdateRequest
bekommt bewusst KEINEN eigenen Encoder: der synthetisierte laesst nil-Felder weg,
ein handgeschriebener wuerde "name": null senden und den Namen loeschen.

Neue Datei -> xcodegen generate; Projektdatei ist mit committet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 00:02:43 +02:00

749 lines
31 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
@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"
@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 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") {
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.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")
}
}
}
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: " · ")
}
/// 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"
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<String> {
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.
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<String> = []
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
}
}
}