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

@@ -76,7 +76,10 @@ struct ShoppingListView: View {
ListRow(
systemImage: "square.stack.3d.up",
title: group.name,
subtitle: "fehlt \(formatAmount(group.deficit)) \(group.unitName) · \(group.productCount) Artikel"
subtitle: "fehlt \(formatAmount(group.deficit)) \(group.unitName)"
+ " · \(group.productCount) Artikel"
+ ((group.subgroupCount ?? 0) > 0
? " · inkl. \(group.subgroupCount ?? 0) Untergruppen" : "")
) { GroupBadge() }
}
}
@@ -185,10 +188,15 @@ struct ExpiringView: View {
// MARK: - Mindestbestände
/// Übersicht aller Mindestbestände: Produkte und Gruppen, jeweils Gesamt UND je
/// Lagerort (Soll in der Anzeige-/Gruppeneinheit). Produktzeilen öffnen den
/// Artikel zum Bearbeiten; das Setzen von Gruppen-/Lagerort-Bedarfen läuft übers
/// Web.
/// Übersicht aller Mindestbestände nach LAGERORT gegliedert.
///
/// Ein Mindestbestand ist immer ein Bedarf an einem Ort. Überall" (egal wo,
/// Hauptsache im Haus) ist dabei der oberste Ort; Käufe für einen einzelnen
/// Lagerort decken ihn mit ab. Mengen kommen in Basiseinheiten und werden hier
/// in die Erfassungseinheit des Ziels umgerechnet.
///
/// Artikelzeilen öffnen den Artikel, Gruppenzeilen die Gruppe beide lassen
/// sich dort bearbeiten.
struct MinStockView: View {
@State private var products: [Product] = []
@State private var groups: [GroupItem] = []
@@ -198,71 +206,92 @@ struct MinStockView: View {
private struct MinRow: Identifiable {
let id: String
let title: String
let ort: String // "Gesamt" oder Lagerort-Name
let soll: String
let bestand: String?
let productId: Int? // Tippziel (nur Produkte)
let isGroup: Bool
let productId: Int? // Tippziel (Artikel)
let groupId: Int? // Tippziel (Gruppe)
let unter: Bool
let untergruppen: Int
}
private var rows: [MinRow] {
var out: [MinRow] = []
private struct OrtBlock: Identifiable {
let id: String
let name: String
let zeilen: [MinRow]
}
/// Alle Zeilen, gebündelt je Ort. Überall" (Ort nil) steht zuerst.
private var bloecke: [OrtBlock] {
var jeOrt: [String: [MinRow]] = [:]
var namen: [String: String] = [UEBERALL_ID: UEBERALL_NAME]
func merken(_ locationId: String?, _ locationName: String?, _ row: MinRow) {
let key = locationId ?? UEBERALL_ID
if let n = locationName, locationId != nil { namen[key] = n }
jeOrt[key, default: []].append(row)
}
for p in products.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) {
let disp = p.unitFactor == 0 ? 1 : p.unitFactor // Basis -> Anzeigeeinheit
let art = p.articleUnitFactor == 0 ? 1 : p.articleUnitFactor
if let ms = p.minStock, ms > 0 {
out.append(MinRow(
id: "p\(p.id)g", title: p.name, ort: "Gesamt",
soll: "\(formatAmount(ms / disp)) \(p.unitName)",
bestand: "\(formatAmount(p.stock / disp)) \(p.unitName)",
productId: p.id, isGroup: false, unter: p.stock < ms))
}
for l in (p.locationMinStocks ?? []) {
// je Lagerort in Artikeleinheiten gespeichert -> Anzeigeeinheit.
out.append(MinRow(
id: "p\(p.id)l\(l.locationId)", title: p.name,
ort: l.locationName ?? "Lagerort",
soll: "\(formatAmount(l.minStock * art / disp)) \(p.unitName)",
bestand: nil, productId: p.id, isGroup: false, unter: false))
merken(l.locationId, l.locationName, MinRow(
id: "p\(p.id)l\(l.id)", title: p.name,
soll: "\(formatAmount(l.minStock / disp)) \(p.unitName)",
bestand: l.stock.map { "\(formatAmount($0 / disp)) \(p.unitName)" },
productId: p.id, groupId: nil,
unter: (l.stock ?? 0) < l.minStock, untergruppen: 0))
}
}
for g in groups.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) {
let unit = g.minStockUnitName ?? ""
if let ms = g.minStock, ms > 0 {
out.append(MinRow(
id: "g\(g.id)g", title: g.name, ort: "Gesamt",
soll: "\(formatAmount(ms)) \(unit)",
bestand: g.stock.map { "\(formatAmount($0)) \(unit)" },
productId: nil, isGroup: true, unter: (g.stock ?? 0) < ms))
}
let faktor = g.minFaktor == 0 ? 1 : g.minFaktor
let einheit = g.minEinheit
for l in (g.locationMinStocks ?? []) {
out.append(MinRow(
id: "g\(g.id)l\(l.locationId)", title: g.name,
ort: l.locationName ?? "Lagerort",
soll: "\(formatAmount(l.minStock)) \(unit)",
bestand: nil, productId: nil, isGroup: true, unter: false))
merken(l.locationId, l.locationName, MinRow(
id: "g\(g.id)l\(l.id)", title: g.name,
soll: "\(formatAmount(l.minStock / faktor)) \(einheit)",
bestand: l.stock.map { "\(formatAmount($0 / faktor)) \(einheit)" },
productId: nil, groupId: g.id,
unter: (l.stock ?? 0) < l.minStock,
untergruppen: g.childIds?.count ?? 0))
}
}
return out
// Überall" zuerst, danach die Lagerorte alphabetisch.
let schluessel = jeOrt.keys.sorted { a, b in
if a == UEBERALL_ID { return true }
if b == UEBERALL_ID { return false }
return (namen[a] ?? "").localizedCaseInsensitiveCompare(namen[b] ?? "") == .orderedAscending
}
return schluessel.map {
OrtBlock(id: $0, name: namen[$0] ?? "Lagerort",
zeilen: jeOrt[$0] ?? [])
}
}
var body: some View {
List {
if !busy && rows.isEmpty {
if !busy && bloecke.isEmpty {
Text("Noch keine Mindestbestände gesetzt.").foregroundStyle(.secondary)
}
ForEach(rows) { r in
if let pid = r.productId, let p = products.first(where: { $0.id == pid }) {
NavigationLink { ProductDetailView(product: p) } label: { zeile(r) }
} else {
zeile(r)
ForEach(bloecke) { block in
Section(block.id == UEBERALL_ID ? "Überall (egal wo)" : block.name) {
ForEach(block.zeilen) { r in
if let pid = r.productId, let p = products.first(where: { $0.id == pid }) {
NavigationLink { ProductDetailView(product: p) } label: { zeile(r) }
} else if let gid = r.groupId, let g = groups.first(where: { $0.id == gid }) {
NavigationLink {
GroupEditor(item: g, all: groups) { Task { await load() } }
} label: { zeile(r) }
} else {
zeile(r)
}
}
}
}
}
.navigationTitle("Mindestbestände")
.navigationBarTitleDisplayMode(.inline)
.overlay { if busy && rows.isEmpty { ProgressView() } }
.overlay { if busy && bloecke.isEmpty { ProgressView() } }
.refreshable { await load() }
.onAppear { Task { await load() } }
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
@@ -276,13 +305,18 @@ struct MinStockView: View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(r.title)
if r.isGroup {
if r.groupId != nil {
Text("Gruppe").font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 1)
.background(Color.marke.opacity(0.2), in: Capsule())
}
if r.untergruppen > 0 {
Text("inkl. \(r.untergruppen)").font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 1)
.background(Color.secondary.opacity(0.15), in: Capsule())
}
}
Text("\(r.ort) · Soll \(r.soll)" + (r.bestand.map { " · Bestand \($0)" } ?? ""))
Text("Soll \(r.soll)" + (r.bestand.map { " · Bestand \($0)" } ?? ""))
.font(.caption).foregroundStyle(.secondary)
}
Spacer()