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>
This commit is contained in:
Scarriffle
2026-08-16 00:02:43 +02:00
parent 0537a2b53f
commit f33054c534
8 changed files with 576 additions and 133 deletions

View File

@@ -31,7 +31,6 @@ struct ProductDetailView: View {
@State private var shopId: Int?
@State private var productUrl = ""
// Gesamt-Mindestbestand (nur Gegenstände; Menge in Stück/Artikeleinheit).
@State private var minStock = ""
@State private var shops: [ShopItem] = []
@State private var locations: [StorageLocation] = []
@State private var fieldDefs: [FieldDefinition] = []
@@ -63,8 +62,6 @@ struct ProductDetailView: View {
if packageSize != (current.packageSize.map { formatAmount($0) } ?? "") { return true }
if packageLabel != (current.packageLabel ?? "") { return true }
if datePrecision != (current.datePrecision == "month" ? "month" : "day") { return true }
// Mindestbestand gibt es bei Lebensmitteln und Verbrauchsgegenständen.
if minStock != minStockFeld { return true }
}
if current.isObject {
if objMode != currentObjMode { return true }
@@ -197,26 +194,31 @@ struct ProductDetailView: View {
}
}
// Mindestbestand bei Lebensmitteln UND Verbrauchsgegenständen (Charge)
// in der Anzeigeeinheit (z.B. Gramm/Milliliter), nicht in Packungen.
// Mindestbestand bei Lebensmitteln UND Verbrauchsgegenständen (Charge).
// Menge je Lagerort und Einzelstücke haben keinen Mindestbestand.
if current.foodLike {
Section("Mindestbestand") {
QuantityField(label: "Gesamt (\(current.unitName))", text: $minStock)
}
}
//
// 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 n = (current.locationMinStocks ?? []).count
Label(n > 0 ? "Mindestbestand je Lagerort (\(n))" : "Mindestbestand je Lagerort",
systemImage: "mappin.and.ellipse")
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("Bedarf je Lagerort (z.B. Ferienhaus, Zuhause), zusätzlich zum Gesamt-Mindestbestand.")
Text("„Überall“ heißt: egal wo, Hauptsache im Haus Käufe für einen "
+ "Lagerort decken das mit ab.")
}
}
@@ -329,10 +331,17 @@ struct ProductDetailView: View {
}
}
/// Gesamt-Mindestbestand in der Anzeigeeinheit (Gramm/Milliliter/Stück) aus
/// den Basiseinheiten umgerechnet, unabhängig davon, wie er erfasst wurde.
private var minStockFeld: String {
current.minStock.map { formatAmount($0 / max(current.unitFactor, 1)) } ?? ""
/// 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.
@@ -350,7 +359,6 @@ struct ProductDetailView: View {
categoryId = current.categoryId
shopId = current.shopId
productUrl = current.productUrl ?? ""
minStock = minStockFeld
objMode = currentObjMode
fieldValues = Self.stringValues(current.fieldValues)
}
@@ -411,9 +419,6 @@ struct ProductDetailView: View {
busy = true
defer { busy = false }
do {
// Mindestbestand in der Anzeigeeinheit erfasst Basiseinheiten.
let minBase = Double(minStock.replacingOccurrences(of: ",", with: "."))
.map { $0 * current.unitFactor }
if current.foodLike {
// Lebensmittel ODER Verbrauchsgegenstand: als Charge mit Einheit/
// Packung/MHD/Gruppe. Verbrauchsgegenstände (isObject) tragen
@@ -427,11 +432,9 @@ struct ProductDetailView: View {
groupId: groupId,
categoryId: categoryId
)
// Mindestbestand für Lebensmittel UND Verbrauchsgegenstände.
req.minStock = minBase
req.minStockInPackages = false
req.minStockUnitId = nil
req.sendMinStock = true
// 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)] ?? "" }
@@ -660,22 +663,21 @@ struct ProductLocationMinView: View {
@State private var busy = false
@State private var error: String?
// Speicherung in Artikeleinheiten, Anzeige in der Anzeigeeinheit (Gramm/ml/Stück).
private var artToDisp: Double {
let disp = product.unitFactor == 0 ? 1 : product.unitFactor
return product.articleUnitFactor / disp
}
// 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("Bedarf je Lagerort (z.B. Ferienhaus, Zuhause). Menge in \(product.unitName).")
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("Lagerort", selection: $row.locationId) {
Text(" wählen ").tag(String?.none)
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)
@@ -691,15 +693,15 @@ struct ProductLocationMinView: View {
}
}
Button {
rows.append(MinRow(locationId: nil, amount: ""))
rows.append(MinRow(locationId: UEBERALL_ID, amount: ""))
} label: {
Label("Lagerort hinzufügen", systemImage: "plus")
Label("Mindestbestand hinzufügen", systemImage: "plus")
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
}
.navigationTitle("Bedarf je Lagerort")
.navigationTitle("Mindestbestand")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
@@ -712,8 +714,10 @@ struct ProductLocationMinView: View {
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, amount: formatAmount($0.minStock * artToDisp))
MinRow(locationId: $0.locationId ?? UEBERALL_ID,
amount: formatAmount($0.minStock / dispFaktor))
}
}
@@ -727,8 +731,10 @@ struct ProductLocationMinView: View {
let wert = Double(row.amount.replacingOccurrences(of: ",", with: ".")) ?? 0
if wert > 0 {
gesehen.insert(loc)
// Eingabe in Anzeigeeinheit Artikeleinheiten (so gespeichert).
list.append(LocationMinStockIn(locationId: loc, minStock: wert / artToDisp))
// Eingabe in Anzeigeeinheit Basiseinheiten (so gespeichert).
list.append(LocationMinStockIn(
locationId: loc == UEBERALL_ID ? nil : loc,
minStock: wert * dispFaktor))
}
}
do {