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("\(n) 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 = [] @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) -> 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 = [] 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 { var ergebnis: Set = [] 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 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) } }