Anzeigefehler: die Gruppenliste klebte immer min_stock_unit_name hinter den
Bestand - auch wenn die Gruppe laengst im Gruppen-Gebinde zaehlt. Aus 790 g bei
einem 190-g-Glas wurde so "4,16 Gramm" statt "4,16 Glaeser". Gerechnet war
richtig, beschriftet falsch.
Ursache war doppelte Logik: MinStock.jsx hatte grpPkgMode/grpFactor/grpUnit
lokal, Groups.jsx gar nicht. Die drei stehen jetzt in units.js und werden von
beiden benutzt. Die Einheiten-Spalte sagt ausserdem, wenn eine Gruppe im
Gebinde zaehlt ("zaehlt in Glaeser a 190 g") und bietet den Weg zurueck an.
Gruppenliste als Baum: eine Gruppe mit zwei Obergruppen erscheint unter beiden -
das ist die ehrliche Darstellung eines Graphen. Jede Zeile traegt ihren PFAD als
Schluessel, damit die vorhandene Baumlogik der DataTable unveraendert zurecht
kommt; die kennt nur einen Elternteil je Zeile, und ein Pfad hat genau einen.
Beim Sortieren/Filtern flacht sie wie gewohnt ab.
"1 Untergruppen" heisst jetzt "1 Untergruppe" - an allen vier Stellen ueber
einen gemeinsamen Helfer (anzahlWort), Web und App.
Lebensmittelliste bekommt eine Spalte "Gruppe" mit Filter. Dafuer liefert
ProductOut jetzt group_name, symmetrisch zu category_name, mit joinedload gegen
N+1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
330 lines
12 KiB
Swift
330 lines
12 KiB
Swift
import SwiftUI
|
||
|
||
// Gruppen: Stammdaten, Obergruppen und Mindestbestände.
|
||
//
|
||
// Gruppen bilden einen Graphen, keinen Baum: eine Gruppe darf unter MEHREREN
|
||
// Obergruppen hängen („Grillwurst" unter „Wurst" UND unter „Grillgut").
|
||
// `TreeMasterView` (MasterDataViews.swift) kann nur einen Elternteil und würde
|
||
// dieselbe Gruppe doppelt anzeigen – deshalb hier eine eigene, flache Ansicht
|
||
// statt der Baumdarstellung der Lagerorte.
|
||
|
||
/// Gruppenliste mit „unter: …" als Untertitel.
|
||
struct GroupsView: View {
|
||
@State private var groups: [GroupItem] = []
|
||
@State private var busy = true
|
||
@State private var error: String?
|
||
@State private var editing: GroupItem?
|
||
@State private var anlegen = false
|
||
@State private var loeschen: GroupItem?
|
||
|
||
private var sortiert: [GroupItem] {
|
||
groups.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||
}
|
||
|
||
var body: some View {
|
||
List {
|
||
if !busy && groups.isEmpty {
|
||
Text("Noch keine Gruppen.").foregroundStyle(.secondary)
|
||
}
|
||
ForEach(sortiert) { g in
|
||
Button { editing = g } label: { zeile(g) }
|
||
.buttonStyle(.plain)
|
||
.swipeActions {
|
||
Button(role: .destructive) { loeschen = g } label: {
|
||
Label("Löschen", systemImage: "trash")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("Gruppen")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button { anlegen = true } label: { Image(systemName: "plus") }
|
||
}
|
||
}
|
||
.overlay { if busy && groups.isEmpty { ProgressView() } }
|
||
.refreshable { await load() }
|
||
.task { await load() }
|
||
.sheet(item: $editing) { g in
|
||
NavigationStack { GroupEditor(item: g, all: groups) { Task { await load() } } }
|
||
}
|
||
.sheet(isPresented: $anlegen) {
|
||
NavigationStack { GroupEditor(item: nil, all: groups) { Task { await load() } } }
|
||
}
|
||
.confirmationDialog("Gruppe löschen?", isPresented: Binding(
|
||
get: { loeschen != nil }, set: { if !$0 { loeschen = nil } }
|
||
), titleVisibility: .visible) {
|
||
Button("Löschen", role: .destructive) {
|
||
if let g = loeschen { Task { await entfernen(g) } }
|
||
}
|
||
Button("Abbrechen", role: .cancel) { loeschen = nil }
|
||
} message: {
|
||
Text("Die Artikel bleiben erhalten und verlieren nur ihre Zuordnung. "
|
||
+ "Untergruppen bleiben ebenfalls bestehen – sie rücken nicht nach oben.")
|
||
}
|
||
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||
Button("OK", role: .cancel) {}
|
||
} message: { Text(error ?? "") }
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func zeile(_ g: GroupItem) -> some View {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(g.name).foregroundStyle(.primary)
|
||
Text(untertitel(g)).font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.contentShape(Rectangle())
|
||
}
|
||
|
||
private func untertitel(_ g: GroupItem) -> String {
|
||
let map = Dictionary(groups.map { ($0.id, $0.name) }, uniquingKeysWith: { a, _ in a })
|
||
let eltern = (g.parentIds ?? []).compactMap { map[$0] }
|
||
var teile = [eltern.isEmpty ? "oberste Ebene" : "unter: " + eltern.joined(separator: ", ")]
|
||
teile.append("\(g.productCount ?? 0) Artikel")
|
||
if let n = g.childIds?.count, n > 0 {
|
||
teile.append(anzahlWort(n, "Untergruppe", "Untergruppen"))
|
||
}
|
||
return teile.joined(separator: " · ")
|
||
}
|
||
|
||
private func load() async {
|
||
busy = true
|
||
defer { busy = false }
|
||
do { groups = try await APIClient.shared.groups() }
|
||
catch { self.error = error.localizedDescription }
|
||
}
|
||
|
||
private func entfernen(_ g: GroupItem) async {
|
||
loeschen = nil
|
||
do {
|
||
try await APIClient.shared.deleteGroup(id: g.id)
|
||
await load()
|
||
} catch { self.error = error.localizedDescription }
|
||
}
|
||
}
|
||
|
||
/// Gruppe anlegen/ändern: Name, Obergruppen, Einheit und Mindestbestände je Ort.
|
||
struct GroupEditor: View {
|
||
let item: GroupItem?
|
||
let all: [GroupItem]
|
||
var done: () -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
private struct MinRow: Identifiable {
|
||
let id = UUID()
|
||
var locationId: String?
|
||
var amount: String
|
||
}
|
||
|
||
@State private var name = ""
|
||
@State private var parentIds: Set<Int> = []
|
||
@State private var rows: [MinRow] = []
|
||
@State private var units: [Unit] = []
|
||
@State private var unitId: Int?
|
||
@State private var locations: [StorageLocation] = []
|
||
@State private var busy = false
|
||
@State private var error: String?
|
||
|
||
/// Basiseinheiten je Erfassungseinheit – gespeichert wird in Basiseinheiten.
|
||
private var faktor: Double {
|
||
let f = item?.minFaktor ?? 1
|
||
return f == 0 ? 1 : f
|
||
}
|
||
private var einheit: String { item?.minEinheit ?? "" }
|
||
|
||
/// Auswahlmenge für Obergruppen: alles außer der Gruppe selbst und ihren
|
||
/// Untergruppen – sonst entstünde ein Ring.
|
||
private var moeglicheEltern: [GroupItem] {
|
||
guard let item else { return all }
|
||
let verboten = Self.nachfahren(of: item.id, in: all).union([item.id])
|
||
return all.filter { !verboten.contains($0.id) }
|
||
}
|
||
|
||
private var elternText: String {
|
||
let map = Dictionary(all.map { ($0.id, $0.name) }, uniquingKeysWith: { a, _ in a })
|
||
let liste = parentIds.compactMap { map[$0] }.sorted()
|
||
return liste.isEmpty ? "– keine –" : liste.joined(separator: ", ")
|
||
}
|
||
|
||
private var mengenTitel: String {
|
||
einheit.isEmpty ? "Mindestbestand" : "Mindestbestand (in \(einheit))"
|
||
}
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section("Name") {
|
||
TextField("z.B. Wurst", text: $name)
|
||
}
|
||
|
||
Section {
|
||
NavigationLink {
|
||
GroupParentPicker(auswahl: $parentIds, moeglich: moeglicheEltern)
|
||
} label: {
|
||
LabeledContent("Obergruppen", value: elternText)
|
||
}
|
||
} footer: {
|
||
Text("Eine Gruppe darf unter mehreren Obergruppen hängen – „Grillwurst“ "
|
||
+ "etwa unter „Wurst“ und unter „Grillgut“. Bestand und Mindestbestand "
|
||
+ "der Obergruppe zählen sie dann mit.")
|
||
}
|
||
|
||
if item != nil {
|
||
Section("Einheit") {
|
||
Picker("Einheit", selection: $unitId) {
|
||
Text("– Basiseinheit –").tag(Int?.none)
|
||
ForEach(units) { u in Text(u.name).tag(Int?.some(u.id)) }
|
||
}
|
||
}
|
||
|
||
Section {
|
||
ForEach($rows) { $row in
|
||
mengenZeile($row)
|
||
}
|
||
Button {
|
||
rows.append(MinRow(locationId: UEBERALL_ID, amount: ""))
|
||
} label: {
|
||
Label("Mindestbestand hinzufügen", systemImage: "plus")
|
||
}
|
||
} header: {
|
||
Text(mengenTitel)
|
||
} footer: {
|
||
Text("„Überall“ heißt: egal wo, Hauptsache im Haus – Käufe für einen "
|
||
+ "Lagerort decken das mit ab.")
|
||
}
|
||
}
|
||
|
||
if let error {
|
||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||
}
|
||
}
|
||
.navigationTitle(item == nil ? "Gruppe anlegen" : "Gruppe ändern")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button(busy ? "Speichern…" : "Speichern") { Task { await save() } }
|
||
.disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty)
|
||
}
|
||
}
|
||
.task { await load() }
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func mengenZeile(_ row: Binding<MinRow>) -> some View {
|
||
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) {
|
||
let id = row.wrappedValue.id
|
||
rows.removeAll { $0.id == id }
|
||
} label: { Image(systemName: "trash") }
|
||
.buttonStyle(.borderless)
|
||
}
|
||
}
|
||
|
||
private func load() async {
|
||
name = item?.name ?? ""
|
||
parentIds = Set(item?.parentIds ?? [])
|
||
unitId = item?.minStockUnitId
|
||
units = (try? await APIClient.shared.units()) ?? []
|
||
locations = (try? await APIClient.shared.locations()) ?? []
|
||
rows = (item?.locationMinStocks ?? []).map {
|
||
MinRow(locationId: $0.locationId ?? UEBERALL_ID,
|
||
amount: formatAmount($0.minStock / faktor))
|
||
}
|
||
}
|
||
|
||
private func save() async {
|
||
busy = true
|
||
defer { busy = false }
|
||
let sauber = name.trimmingCharacters(in: .whitespaces)
|
||
do {
|
||
guard let item else {
|
||
_ = try await APIClient.shared.createGroup(NewGroupRequest(
|
||
name: sauber, minStock: nil, minStockUnitId: unitId,
|
||
parentIds: Array(parentIds)))
|
||
done()
|
||
dismiss()
|
||
return
|
||
}
|
||
_ = try await APIClient.shared.updateGroup(id: item.id, GroupUpdateRequest(
|
||
name: sauber, minStockUnitId: unitId, parentIds: Array(parentIds)))
|
||
var liste: [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 der Erfassungseinheit → Basiseinheiten.
|
||
liste.append(LocationMinStockIn(
|
||
locationId: loc == UEBERALL_ID ? nil : loc,
|
||
minStock: wert * faktor))
|
||
}
|
||
}
|
||
_ = try await APIClient.shared.setGroupLocationMinStock(id: item.id, liste)
|
||
done()
|
||
dismiss()
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
/// Alle Untergruppen (transitiv). Menge statt Baum-Walk: eine Gruppe ist
|
||
/// über mehrere Wege erreichbar und würde sonst mehrfach besucht.
|
||
private static func nachfahren(of id: Int, in all: [GroupItem]) -> Set<Int> {
|
||
var ergebnis: Set<Int> = []
|
||
var offen = [id]
|
||
while let cur = offen.popLast() {
|
||
for kind in all.first(where: { $0.id == cur })?.childIds ?? [] {
|
||
if ergebnis.insert(kind).inserted { offen.append(kind) }
|
||
}
|
||
}
|
||
return ergebnis
|
||
}
|
||
}
|
||
|
||
/// Mehrfachauswahl der Obergruppen. Ein Picker kann das nicht – deshalb eine
|
||
/// Liste mit Häkchen, das SwiftUI-Idiom dafür.
|
||
struct GroupParentPicker: View {
|
||
@Binding var auswahl: Set<Int>
|
||
let moeglich: [GroupItem]
|
||
|
||
private var sortiert: [GroupItem] {
|
||
moeglich.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||
}
|
||
|
||
var body: some View {
|
||
List {
|
||
ForEach(sortiert) { g in
|
||
Button {
|
||
if auswahl.contains(g.id) { auswahl.remove(g.id) } else { auswahl.insert(g.id) }
|
||
} label: {
|
||
HStack {
|
||
Text(g.name).foregroundStyle(.primary)
|
||
Spacer()
|
||
if auswahl.contains(g.id) {
|
||
Image(systemName: "checkmark").foregroundStyle(Color.marke)
|
||
}
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.navigationTitle("Obergruppen")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
}
|
||
}
|