From f33054c53422e700e1ef07d5b13d012ffca2d534 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 16 Aug 2026 00:02:43 +0200 Subject: [PATCH] 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 --- ios/Sources/APIClient.swift | 14 ++ ios/Sources/GroupViews.swift | 327 +++++++++++++++++++++++++ ios/Sources/ListViews.swift | 128 ++++++---- ios/Sources/MasterDataViews.swift | 28 --- ios/Sources/Models.swift | 102 +++++++- ios/Sources/ProductDetailView.swift | 90 +++---- ios/Sources/Vorrania.entitlements | 4 + ios/Vorrania.xcodeproj/project.pbxproj | 16 +- 8 files changed, 576 insertions(+), 133 deletions(-) create mode 100644 ios/Sources/GroupViews.swift diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index 79e2f18..80f9f04 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -527,6 +527,20 @@ actor APIClient { try await sendNoContent(try makeRequest("/categories/\(id)", method: "DELETE")) } + /// Gruppe aendern (Name, Mindestbestand, Einheit, Obergruppen). + func updateGroup(id: Int, _ payload: GroupUpdateRequest) async throws -> GroupItem { + var request = try makeRequest("/groups/\(id)", method: "PATCH") + try jsonBody(&request, payload) + return try await send(request, as: GroupItem.self) + } + + /// Mindestbestaende einer Gruppe je Lagerort ersetzen (Basiseinheiten). + func setGroupLocationMinStock(id: Int, _ list: [LocationMinStockIn]) async throws -> GroupItem { + var request = try makeRequest("/groups/\(id)/location-min-stock", method: "PUT") + try jsonBody(&request, list) + return try await send(request, as: GroupItem.self) + } + func renameGroup(id: Int, name: String) async throws -> GroupItem { var request = try makeRequest("/groups/\(id)", method: "PATCH") try jsonBody(&request, RenameRequest(name: name)) diff --git a/ios/Sources/GroupViews.swift b/ios/Sources/GroupViews.swift new file mode 100644 index 0000000..7ab874c --- /dev/null +++ b/ios/Sources/GroupViews.swift @@ -0,0 +1,327 @@ +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) + } +} diff --git a/ios/Sources/ListViews.swift b/ios/Sources/ListViews.swift index 39cf856..7c5f756 100644 --- a/ios/Sources/ListViews.swift +++ b/ios/Sources/ListViews.swift @@ -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() diff --git a/ios/Sources/MasterDataViews.swift b/ios/Sources/MasterDataViews.swift index 92a537a..4a737c1 100644 --- a/ios/Sources/MasterDataViews.swift +++ b/ios/Sources/MasterDataViews.swift @@ -1126,34 +1126,6 @@ struct FieldEditor: View { } } -struct GroupsView: View { - var body: some View { - MasterDataListView( - title: "Gruppen", - singular: "Die Gruppe", - load: { - try await APIClient.shared.groups().map { - MasterDataItem(id: $0.id, title: $0.name, subtitle: "", isBuiltin: false) - } - }, - delete: { try await APIClient.shared.deleteGroup(id: $0) } - ) { item, done in - NameEditor( - title: item == nil ? "Gruppe anlegen" : "Gruppe umbenennen", - initial: item?.title ?? "", - save: { name in - if let item { - _ = try await APIClient.shared.renameGroup(id: item.id, name: name) - } else { - _ = try await APIClient.shared.createGroup( - NewGroupRequest(name: name, minStock: nil, minStockUnitId: nil)) - } - }, - done: done - ) - } - } -} /// Bezugsquellen für „gekauft bei" (nur Gegenstände). Wie im Web pflegbar – /// Name plus optionale Website. diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index adff696..12f6352 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -146,30 +146,50 @@ struct Product: Codable, Identifiable, Hashable { } /// Mindestbestand eines Produkts/einer Gruppe an einem Lagerort (Anzeige). +/// +/// ``locationId`` nil heisst „Ueberall": egal wo, Hauptsache die Menge ist im +/// Haus. Das ersetzt den frueheren separaten Gesamt-Mindestbestand. Mengen in +/// Basiseinheiten (g/ml/Stueck). struct LocationMinStock: Codable, Hashable, Identifiable { - let locationId: String + let locationId: String? let locationName: String? let minStock: Double - var id: String { locationId } + /// Bestand an diesem Ort (inkl. Unterorte), ebenfalls in Basiseinheiten. + var stock: Double? = nil + var id: String { locationId ?? UEBERALL_ID } enum CodingKeys: String, CodingKey { + case stock case minStock = "min_stock" case locationId = "location_id" case locationName = "location_name" } } -/// Ein Mindestbestand-Eintrag zum Speichern (Menge in Artikeleinheiten). +/// Ein Mindestbestand-Eintrag zum Speichern (Menge in Basiseinheiten). +/// ``locationId`` nil = „Ueberall". struct LocationMinStockIn: Codable { - let locationId: String + let locationId: String? let minStock: Double enum CodingKeys: String, CodingKey { case minStock = "min_stock" case locationId = "location_id" } + + // Den Ort immer senden – auch null, sonst laesst sich „Ueberall" gar nicht + // ausdruecken (Swift laesst nil-Optionals sonst weg). + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(locationId, forKey: .locationId) + try c.encode(minStock, forKey: .minStock) + } } +/// Platzhalter-ID fuer „Ueberall" in Pickern – im JSON ist es schlicht null. +let UEBERALL_ID = "__ueberall__" +let UEBERALL_NAME = "Überall" + /// Auswahleintrag fuer Einheiten. /// /// Anzeige und uebertragener Wert sind bewusst getrennt: Das Backend kennt @@ -390,6 +410,8 @@ struct GroupShoppingItem: Codable, Identifiable { let deficit: Double let unitName: String let productCount: Int + /// Wie viele Untergruppen mitgezaehlt werden (0 = keine). + var subgroupCount: Int? = nil var id: Int { groupId } @@ -399,6 +421,7 @@ struct GroupShoppingItem: Codable, Identifiable { case minStock = "min_stock" case unitName = "unit_name" case productCount = "product_count" + case subgroupCount = "subgroup_count" } } @@ -637,20 +660,86 @@ struct SettingEntry: Codable { } /// Gruppe: fasst Bestaende mehrerer Marken zusammen (z.B. "Mehl"). +/// +/// Gruppen bilden einen Graphen, keinen Baum: eine Gruppe darf unter MEHREREN +/// Obergruppen haengen („Grillwurst" unter „Wurst" UND unter „Grillgut"). +/// Deshalb ``parentIds`` als Liste – und deshalb konformiert GroupItem NICHT zu +/// ``TreeItem``, das genau einen Elternteil kennt. struct GroupItem: Codable, Identifiable, Hashable { let id: Int let name: String // Mindestbestand-Infos (nur aus /groups befuellt; Picker brauchen sie nicht). var minStock: Double? = nil + var minStockUnitId: Int? = nil var minStockUnitName: String? = nil + var minStockUnitFactor: Double? = nil + var kind: String? = nil + var packageSize: Double? = nil + var packageLabel: String? = nil + var minStockInPackages: Bool? = nil var stock: Double? = nil var locationMinStocks: [LocationMinStock]? = nil + var parentIds: [Int]? = nil + var childIds: [Int]? = nil + /// Artikel inkl. Untergruppen; ``directProductCount`` nur die eigenen. + var productCount: Int? = nil + var directProductCount: Int? = nil enum CodingKeys: String, CodingKey { - case id, name, stock + case id, name, stock, kind case minStock = "min_stock" + case minStockUnitId = "min_stock_unit_id" case minStockUnitName = "min_stock_unit_name" + case minStockUnitFactor = "min_stock_unit_factor" + case packageSize = "package_size" + case packageLabel = "package_label" + case minStockInPackages = "min_stock_in_packages" case locationMinStocks = "location_min_stocks" + case parentIds = "parent_ids" + case childIds = "child_ids" + case productCount = "product_count" + case directProductCount = "direct_product_count" + } + + /// Basiseinheiten je Erfassungseinheit – zum Umrechnen der Mindestbestaende, + /// die in Basiseinheiten gespeichert sind. + var minFaktor: Double { + if minStockInPackages == true, let s = packageSize, s > 0 { return s } + return minStockUnitFactor ?? 1 + } + + /// Beschriftung der Erfassungseinheit („Glas", „Gramm", …). + var minEinheit: String { + if minStockInPackages == true, (packageSize ?? 0) > 0 { + return packageLabel ?? "Packung" + } + if let n = minStockUnitName { return n } + switch kind { + case "weight": return "g" + case "volume": return "ml" + case "count": return "Stück" + default: return "" + } + } +} + +/// Gruppe aendern. Nur mitgeschickte Felder wertet das Backend aus – deshalb +/// KEIN eigener Encoder: der synthetisierte laesst nil-Optionals weg +/// (encodeIfPresent). Ein handgeschriebener wuerde ``"name": null`` senden und +/// damit den Namen loeschen. +struct GroupUpdateRequest: Codable { + var name: String? = nil + var minStock: Double? = nil + var minStockUnitId: Int? = nil + var minStockInPackages: Bool? = nil + var parentIds: [Int]? = nil + + enum CodingKeys: String, CodingKey { + case name + case minStock = "min_stock" + case minStockUnitId = "min_stock_unit_id" + case minStockInPackages = "min_stock_in_packages" + case parentIds = "parent_ids" } } @@ -1283,10 +1372,13 @@ struct NewGroupRequest: Codable { let name: String let minStock: Double? let minStockUnitId: Int? + /// Obergruppen (mehrere moeglich) – optional, Standard: keine. + var parentIds: [Int]? = nil enum CodingKeys: String, CodingKey { case name case minStock = "min_stock" case minStockUnitId = "min_stock_unit_id" + case parentIds = "parent_ids" } } diff --git a/ios/Sources/ProductDetailView.swift b/ios/Sources/ProductDetailView.swift index 719d5e4..abbc584 100644 --- a/ios/Sources/ProductDetailView.swift +++ b/ios/Sources/ProductDetailView.swift @@ -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 { diff --git a/ios/Sources/Vorrania.entitlements b/ios/Sources/Vorrania.entitlements index 6631ffa..4e3230e 100644 --- a/ios/Sources/Vorrania.entitlements +++ b/ios/Sources/Vorrania.entitlements @@ -2,5 +2,9 @@ + com.apple.developer.associated-domains + + applinks:vorrania-ch.scarriffle.com + diff --git a/ios/Vorrania.xcodeproj/project.pbxproj b/ios/Vorrania.xcodeproj/project.pbxproj index 2bf4169..b68893f 100644 --- a/ios/Vorrania.xcodeproj/project.pbxproj +++ b/ios/Vorrania.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */; }; 17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */; }; 25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */; }; + 34787DA16A1AB035BF129BB7 /* GroupViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ED41226394E7F31F37EC3B8 /* GroupViews.swift */; }; 38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */; }; 39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F1B2DE567B62169E84426 /* APIClient.swift */; }; 44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF19A735AC17451B57BF5BB /* ScannerView.swift */; }; @@ -51,6 +52,7 @@ 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInView.swift; sourceTree = ""; }; 090485CC54558EB4433376A9 /* DashboardCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardCards.swift; sourceTree = ""; }; 099CCD94907BE0179B7D5742 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = ""; }; + 0ED41226394E7F31F37EC3B8 /* GroupViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupViews.swift; sourceTree = ""; }; 121D44B9990DA931A9CD5847 /* ServerProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerProfiles.swift; sourceTree = ""; }; 163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationScheduler.swift; sourceTree = ""; }; 1847E65D41ACEDD01CD108BC /* ServerFetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerFetch.swift; sourceTree = ""; }; @@ -61,7 +63,7 @@ 3731608B8D48960DC98912BC /* NotificationSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettings.swift; sourceTree = ""; }; 378F1B2DE567B62169E84426 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; 4741D0E95875919C921945CF /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; - 4BF4E4F5524B15728CEEE116 /* Vorrania.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Vorrania.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 4BF4E4F5524B15728CEEE116 /* Vorrania.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Vorrania.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5312AFE88B7284401AA7B0A3 /* ProductPhotoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPhotoView.swift; sourceTree = ""; }; 660ACEFA22BC77D52FBE736A /* AppDashboards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDashboards.swift; sourceTree = ""; }; 69B59B9F69BC30451D45A3C7 /* ItemViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViews.swift; sourceTree = ""; }; @@ -105,6 +107,7 @@ E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */, 717C8EB336170526F5F3E695 /* DateScanView.swift */, AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */, + 0ED41226394E7F31F37EC3B8 /* GroupViews.swift */, 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */, 00288DFA409BAD753AA4CA5D /* ItemListView.swift */, 69B59B9F69BC30451D45A3C7 /* ItemViews.swift */, @@ -234,6 +237,7 @@ 926451E72FD13708C5DD657B /* DashboardView.swift in Sources */, ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */, D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */, + 34787DA16A1AB035BF129BB7 /* GroupViews.swift in Sources */, 4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */, 749139BEFF35F9DAFFE87C3B /* ItemListView.swift in Sources */, 56A5DFAC86DF972B1481A691 /* ItemViews.swift in Sources */, @@ -329,7 +333,6 @@ DEVELOPMENT_TEAM = PP34X97WS3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Sources/Info.plist; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -337,10 +340,6 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.vorrania; SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -356,7 +355,6 @@ DEVELOPMENT_TEAM = PP34X97WS3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Sources/Info.plist; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -364,10 +362,6 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.vorrania; SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = "1,2"; };