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"; };