From 956a472c04e854aac819488f1e0f36a2fc53221c Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 26 Jul 2026 13:01:28 +0200 Subject: [PATCH] iOS: Kategorie-Typfilter + Lagerort-Hierarchie (aufklappbarer Baum) Gemeinsame TreeMasterView fuer hierarchische Stammdaten (Ein-/Ausklappen, Anlegen, Bearbeiten, Loeschen): - Kategorien: Segment-Filter Alle/Lebensmittel/Gegenstaende (wie im Web); in der Alle-Ansicht zeigt jede Zeile ihre Art. Anlegen/Bearbeiten waehlt die Verwaltungsart (neu: createCategory mit tracking, updateCategory). - Lagerorte: werden jetzt als aufklappbarer Baum gezeigt statt flach. Beim Anlegen laesst sich ein Elternort waehlen (Hierarchie aufbauen); Umhaengen unterstuetzt der Server nicht, Bearbeiten bleibt daher Umbenennen. Co-Authored-By: Claude Opus 4.8 --- ios/Sources/APIClient.swift | 6 + ios/Sources/MasterDataViews.swift | 538 ++++++++++++++++++++---------- ios/Sources/Models.swift | 20 +- 3 files changed, 394 insertions(+), 170 deletions(-) diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index d75d26d..c3a2485 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -410,6 +410,12 @@ actor APIClient { return try await send(request, as: CategoryItem.self) } + func updateCategory(id: Int, _ payload: CategoryUpdateRequest) async throws -> CategoryItem { + var request = try makeRequest("/categories/\(id)", method: "PATCH") + try jsonBody(&request, payload) + return try await send(request, as: CategoryItem.self) + } + func deleteCategory(id: Int) async throws { try await sendNoContent(try makeRequest("/categories/\(id)", method: "DELETE")) } diff --git a/ios/Sources/MasterDataViews.swift b/ios/Sources/MasterDataViews.swift index 43143a2..38eda76 100644 --- a/ios/Sources/MasterDataViews.swift +++ b/ios/Sources/MasterDataViews.swift @@ -171,6 +171,185 @@ struct MasterDataListView: View { } } +// MARK: - Aufklappbarer Baum (Kategorien, Lagerorte) + +/// Ein-/ausklappbare Baumliste für hierarchische Stammdaten. Kümmert sich um +/// Baumreihenfolge, Ein-/Ausklappen, Anlegen, Bearbeiten und Löschen. Die +/// Besonderheiten (Kopfzeile mit Filter, Zeilen-Hinweis, Editor) kommen von +/// außen herein, damit Kategorien und Lagerorte dieselbe Mechanik teilen. +struct TreeMasterView: View { + let title: String + let singular: String + let load: () async throws -> [Item] + let delete: (Int) async throws -> Void + /// Optionaler kleiner Hinweis am Zeilenende (z. B. der Kategorie-Typ). + let badge: (Item) -> String? + /// Nur Einträge, die das erfüllen, werden gezeigt (z. B. der Typ-Filter). + let include: (Item) -> Bool + @ViewBuilder let header: () -> Header + @ViewBuilder let editor: (_ editing: Item?, _ all: [Item], _ done: @escaping () -> Void) -> Editor + + @State private var items: [Item] = [] + @State private var collapsed: Set = [] + @State private var busy = true + @State private var error: String? + @State private var editing: Item? + @State private var addShown = false + @State private var pendingDeletion: Item? + + private struct Node: Identifiable { + let item: Item + let depth: Int + var id: Int { item.id } + } + + private var shown: [Item] { items.filter(include) } + + /// Baumreihenfolge mit Tiefe. Waisen (Elternteil gefiltert/gelöscht) gelten + /// als oberste Ebene, damit nichts verschwindet. + private var nodes: [Node] { + let list = shown + let ids = Set(list.map(\.id)) + var result: [Node] = [] + func walk(_ n: Item, _ depth: Int) { + result.append(Node(item: n, depth: depth)) + for child in list.filter({ $0.parentId == n.id }) { walk(child, depth + 1) } + } + for root in list.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) { + walk(root, 0) + } + return result + } + + private func childCount(_ id: Int) -> Int { shown.filter { $0.parentId == id }.count } + + private var visible: [Node] { + let parent = Dictionary(uniqueKeysWithValues: shown.map { ($0.id, $0.parentId) }) + func hidden(_ id: Int) -> Bool { + var p = parent[id] ?? nil + while let cur = p { + if collapsed.contains(cur) { return true } + p = parent[cur] ?? nil + } + return false + } + return nodes.filter { !hidden($0.item.id) } + } + + var body: some View { + VStack(spacing: 0) { + header() + List { + if let error { + Section { Text(error).foregroundStyle(.red).font(.callout) } + } + ForEach(visible) { node in + row(node) + .swipeActions(edge: .trailing) { + Button("Löschen", role: .destructive) { pendingDeletion = node.item } + } + } + if shown.isEmpty && !busy { + Text("Nichts vorhanden.").foregroundStyle(.secondary) + } + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Hinzufügen", systemImage: "plus") { addShown = true } + } + } + .sheet(isPresented: $addShown) { + editor(nil, items) { Task { await reload() } } + } + .sheet(item: $editing) { item in + editor(item, items) { Task { await reload() } } + } + .confirmationDialog( + pendingDeletion.map { "„\($0.name)“ löschen?" } ?? "", + isPresented: Binding(get: { pendingDeletion != nil }, + set: { if !$0 { pendingDeletion = nil } }), + titleVisibility: .visible + ) { + Button("Löschen", role: .destructive) { + if let item = pendingDeletion { Task { await remove(item) } } + pendingDeletion = nil + } + Button("Abbrechen", role: .cancel) { pendingDeletion = nil } + } message: { + Text("Wird \(singular.lowercased()) noch verwendet oder hat Untereinträge, entscheidet der Server.") + } + .refreshable { await reload() } + .task { await reload() } + } + + @ViewBuilder + private func row(_ node: Node) -> some View { + let kinder = childCount(node.item.id) + let zu = collapsed.contains(node.item.id) + HStack(spacing: 8) { + if kinder > 0 { + Button { + withAnimation(.easeInOut(duration: 0.15)) { toggle(node.item.id) } + } label: { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(zu ? 0 : 90)) + .frame(width: 18) + } + .buttonStyle(.plain) + } else { + Color.clear.frame(width: 18, height: 1) + } + Button { + editing = node.item + } label: { + HStack(spacing: 8) { + Text(node.item.name).foregroundStyle(.primary) + if zu && kinder > 0 { + Text("\(kinder) ausgeblendet").font(.caption2).foregroundStyle(.tertiary) + } + Spacer() + if let hinweis = badge(node.item) { + Text(hinweis).font(.caption2).foregroundStyle(.secondary) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .padding(.leading, CGFloat(node.depth) * 16) + } + + private func toggle(_ id: Int) { + if collapsed.contains(id) { collapsed.remove(id) } else { collapsed.insert(id) } + } + + private func reload() async { + busy = true + defer { busy = false } + do { + items = try await load() + error = nil + } catch { + self.error = error.localizedDescription + } + } + + private func remove(_ item: Item) async { + do { + try await delete(item.id) + error = nil + await reload() + } catch { + self.error = error.localizedDescription + } + } +} + /// Kleiner Editor fuer alles, was nur einen Namen hat. struct NameEditor: View { @Environment(\.dismiss) private var dismiss @@ -236,31 +415,112 @@ struct NameEditor: View { struct LocationsView: View { var body: some View { - MasterDataListView( + TreeMasterView( title: "Lagerorte", singular: "Der Lagerort", - load: { - try await APIClient.shared.locations().map { - MasterDataItem(id: $0.id, title: $0.name, subtitle: "", isBuiltin: false) + load: { try await APIClient.shared.locations() }, + delete: { try await APIClient.shared.deleteLocation(id: $0) }, + badge: { _ in nil }, + include: { _ in true }, + header: { EmptyView() }, + editor: { item, all, done in + LocationEditor(item: item, all: all, done: done) + } + ) + } +} + +/// Lagerort anlegen (mit optionalem Elternort, um die Hierarchie aufzubauen) +/// oder umbenennen. Umhängen unterstützt der Server nicht – beim Bearbeiten +/// gibt es deshalb nur den Namen. +struct LocationEditor: View { + @Environment(\.dismiss) private var dismiss + + let item: StorageLocation? + let all: [StorageLocation] + let done: () -> Void + + @State private var name: String + @State private var parentId: Int? + @State private var error: String? + @State private var busy = false + + init(item: StorageLocation?, all: [StorageLocation], done: @escaping () -> Void) { + self.item = item + self.all = all + self.done = done + _name = State(initialValue: item?.name ?? "") + _parentId = State(initialValue: item?.parentId) + } + + var body: some View { + NavigationStack { + Form { + Section("Name") { + TextField("z. B. Keller", text: $name) } - }, - delete: { try await APIClient.shared.deleteLocation(id: $0) } - ) { item, done in - NameEditor( - title: item == nil ? "Lagerort anlegen" : "Lagerort umbenennen", - initial: item?.title ?? "", - save: { name in - if let item { - _ = try await APIClient.shared.renameLocation(id: item.id, name: name) - } else { - _ = try await APIClient.shared.createLocation( - NewLocationRequest(name: name, parentId: nil)) + // Elternort nur beim Anlegen: der Server kann bestehende Orte + // nicht umhängen. + if item == nil { + Section { + Picker("Übergeordnet", selection: $parentId) { + Text("– oberste Ebene –").tag(Int?.none) + ForEach(all) { loc in + Text(LocationEditor.pfad(loc, in: all)).tag(Int?.some(loc.id)) + } + } + } header: { + Text("Übergeordneter Lagerort (optional)") } - }, - done: done - ) + } + if let error { + Section { Text(error).foregroundStyle(.red).font(.callout) } + } + } + .navigationTitle(item == nil ? "Lagerort anlegen" : "Lagerort umbenennen") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Abbrechen") { dismiss() } + } + ToolbarItem(placement: .topBarTrailing) { + Button(busy ? "Sichern…" : "Sichern") { Task { await submit() } } + .disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty) + } + } } } + + private func submit() async { + busy = true + defer { busy = false } + let n = name.trimmingCharacters(in: .whitespaces) + do { + if let item { + _ = try await APIClient.shared.renameLocation(id: item.id, name: n) + } else { + _ = try await APIClient.shared.createLocation( + NewLocationRequest(name: n, parentId: parentId)) + } + done() + dismiss() + } catch { + self.error = error.localizedDescription + } + } + + /// "Keller → Regal" – der Pfad macht gleiche Namen im flachen Picker + /// unterscheidbar. + private static func pfad(_ loc: StorageLocation, in all: [StorageLocation]) -> String { + let byId = Dictionary(uniqueKeysWithValues: all.map { ($0.id, $0) }) + var teile = [loc.name] + var pid = loc.parentId + while let cur = pid, let parent = byId[cur] { + teile.insert(parent.name, at: 0) + pid = parent.parentId + } + return teile.joined(separator: " → ") + } } struct UnitsView: View { @@ -469,176 +729,116 @@ struct PackageTypeEditor: View { } } -/// Kategorien als ein-/ausklappbarer Baum (wie im Web). Ein Pfeil klappt die -/// Unterkategorien auf und zu; Anlegen, Umbenennen und Löschen bleiben. +/// Kategorien als ein-/ausklappbarer Baum (wie im Web) mit Typ-Filter +/// Lebensmittel/Gegenstände. Anlegen und Bearbeiten wählen die Verwaltungsart. struct CategoriesView: View { - @State private var categories: [CategoryItem] = [] - @State private var collapsed: Set = [] - @State private var busy = true + // "" = alle, sonst "food"/"object". + @State private var typFilter = "" + + var body: some View { + TreeMasterView( + title: "Kategorien", + singular: "Die Kategorie", + load: { try await APIClient.shared.categories() }, + delete: { try await APIClient.shared.deleteCategory(id: $0) }, + // Typ nur in der „Alle"-Ansicht zeigen – gefiltert wäre er redundant. + badge: { typFilter == "" ? CategoriesView.typLabel($0.tracking) : nil }, + include: { typFilter == "" || ($0.tracking ?? "food") == typFilter }, + header: { + Picker("Typ", selection: $typFilter) { + Text("Alle").tag("") + Text("Lebensmittel").tag("food") + Text("Gegenstände").tag("object") + } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.vertical, 8) + }, + editor: { item, _, done in + CategoryEditor(item: item, done: done) + } + ) + } + + static func typLabel(_ tracking: String?) -> String { + tracking == "object" ? "Gegenstand" : "Lebensmittel" + } +} + +/// Kategorie anlegen oder bearbeiten: Name und Verwaltungsart (Lebensmittel vs. +/// Gegenstand). Neue Kategorien entstehen auf oberster Ebene; Verschachteln +/// bleibt der Web-Oberfläche vorbehalten. +struct CategoryEditor: View { + @Environment(\.dismiss) private var dismiss + + let item: CategoryItem? + let done: () -> Void + + @State private var name: String + @State private var tracking: String @State private var error: String? - @State private var editing: CategoryItem? - @State private var addShown = false - @State private var pendingDeletion: CategoryItem? + @State private var busy = false - private struct Node: Identifiable { - let item: CategoryItem - let depth: Int - var id: Int { item.id } - } - - /// Baumreihenfolge mit Tiefe. Waisen (Oberkategorie gelöscht) gelten als - /// oberste Ebene, damit nichts verschwindet. - private var nodes: [Node] { - let ids = Set(categories.map(\.id)) - var result: [Node] = [] - func walk(_ node: CategoryItem, _ depth: Int) { - result.append(Node(item: node, depth: depth)) - for child in categories.filter({ $0.parentId == node.id }) { - walk(child, depth + 1) - } - } - for root in categories.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) { - walk(root, 0) - } - return result - } - - private func childCount(_ id: Int) -> Int { - categories.filter { $0.parentId == id }.count - } - - /// Sichtbar ist ein Knoten, solange keiner seiner Vorfahren zugeklappt ist. - private var visible: [Node] { - let parent = Dictionary(uniqueKeysWithValues: categories.map { ($0.id, $0.parentId) }) - func verborgen(_ id: Int) -> Bool { - var p = parent[id] ?? nil - while let cur = p { - if collapsed.contains(cur) { return true } - p = parent[cur] ?? nil - } - return false - } - return nodes.filter { !verborgen($0.item.id) } + init(item: CategoryItem?, done: @escaping () -> Void) { + self.item = item + self.done = done + _name = State(initialValue: item?.name ?? "") + _tracking = State(initialValue: item?.tracking ?? "object") } var body: some View { - List { - if let error { - Section { Text(error).foregroundStyle(.red).font(.callout) } - } - ForEach(visible) { node in - row(node) - .swipeActions(edge: .trailing) { - Button("Löschen", role: .destructive) { pendingDeletion = node.item } - } - } - if categories.isEmpty && !busy { - Text("Noch keine Kategorien.").foregroundStyle(.secondary) - } - } - .navigationTitle("Kategorien") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Hinzufügen", systemImage: "plus") { addShown = true } - } - } - .sheet(isPresented: $addShown) { - NameEditor( - title: "Kategorie anlegen", - save: { _ = try await APIClient.shared.createCategory( - NewCategoryRequest(name: $0, parentId: nil)) }, - done: { Task { await reload() } } - ) - } - .sheet(item: $editing) { item in - NameEditor( - title: "Kategorie umbenennen", - initial: item.name, - save: { _ = try await APIClient.shared.renameCategory(id: item.id, name: $0) }, - done: { Task { await reload() } } - ) - } - .confirmationDialog( - pendingDeletion.map { "„\($0.name)“ löschen?" } ?? "", - isPresented: Binding(get: { pendingDeletion != nil }, - set: { if !$0 { pendingDeletion = nil } }), - titleVisibility: .visible - ) { - Button("Löschen", role: .destructive) { - if let item = pendingDeletion { Task { await remove(item) } } - pendingDeletion = nil - } - Button("Abbrechen", role: .cancel) { pendingDeletion = nil } - } message: { - Text("Unterkategorien rücken eine Ebene nach oben. Eigene Felder dieser Kategorie werden entfernt.") - } - .refreshable { await reload() } - .task { await reload() } - } - - @ViewBuilder - private func row(_ node: Node) -> some View { - let kinder = childCount(node.item.id) - let zu = collapsed.contains(node.item.id) - HStack(spacing: 8) { - if kinder > 0 { - Button { - withAnimation(.easeInOut(duration: 0.15)) { toggle(node.item.id) } - } label: { - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .rotationEffect(.degrees(zu ? 0 : 90)) - .frame(width: 18) + NavigationStack { + Form { + Section("Name") { + TextField("z. B. Elektronik", text: $name) } - .buttonStyle(.plain) - } else { - Color.clear.frame(width: 18, height: 1) - } - Button { - editing = node.item - } label: { - HStack(spacing: 8) { - Text(node.item.name).foregroundStyle(.primary) - if zu && kinder > 0 { - Text("\(kinder) ausgeblendet") - .font(.caption2).foregroundStyle(.tertiary) + Section { + Picker("Art", selection: $tracking) { + Text("Lebensmittel").tag("food") + Text("Gegenstand").tag("object") } - Spacer() + .pickerStyle(.segmented) + } header: { + Text("Verwaltungsart") + } footer: { + Text("Lebensmittel: Chargen mit Mindesthaltbarkeit. Gegenstand: Menge je Lagerort bzw. Einzelstücke. Unterkategorien erben die Art.") + } + if let error { + Section { Text(error).foregroundStyle(.red).font(.callout) } + } + } + .navigationTitle(item == nil ? "Kategorie anlegen" : "Kategorie bearbeiten") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Abbrechen") { dismiss() } + } + ToolbarItem(placement: .topBarTrailing) { + Button(busy ? "Sichern…" : "Sichern") { Task { await submit() } } + .disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty) } - .contentShape(Rectangle()) } - .buttonStyle(.plain) } - .padding(.leading, CGFloat(node.depth) * 16) } - private func toggle(_ id: Int) { - if collapsed.contains(id) { collapsed.remove(id) } else { collapsed.insert(id) } - } - - private func reload() async { + private func submit() async { busy = true defer { busy = false } + let n = name.trimmingCharacters(in: .whitespaces) do { - categories = try await APIClient.shared.categories() - error = nil + if let item { + _ = try await APIClient.shared.updateCategory( + id: item.id, CategoryUpdateRequest(name: n, tracking: tracking)) + } else { + _ = try await APIClient.shared.createCategory( + NewCategoryRequest(name: n, parentId: nil, tracking: tracking)) + } + done() + dismiss() } catch { self.error = error.localizedDescription } } - - private func remove(_ item: CategoryItem) async { - do { - try await APIClient.shared.deleteCategory(id: item.id) - error = nil - await reload() - } catch { - // Der Server begründet die Ablehnung – die Meldung unverändert zeigen. - self.error = error.localizedDescription - } - } } struct GroupsView: View { diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index 69818be..15addb0 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -446,6 +446,18 @@ struct CategoryItem: Codable, Identifiable, Hashable { var isObject: Bool { tracking == "object" } } +// MARK: - Hierarchische Stammdaten + +/// Gemeinsame Form baumartiger Stammdaten (Kategorien, Lagerorte): eine flache +/// Liste mit Eltern-Verweis, aus der sich der Baum aufbauen lässt. +protocol TreeItem: Identifiable where ID == Int { + var name: String { get } + var parentId: Int? { get } +} + +extension CategoryItem: TreeItem {} +extension StorageLocation: TreeItem {} + /// Shop / Bezugsquelle fuer Gegenstaende ("gekauft bei"). struct ShopItem: Codable, Identifiable, Hashable { let id: Int @@ -856,13 +868,19 @@ struct NewPackageTypeRequest: Codable { struct NewCategoryRequest: Codable { let name: String let parentId: Int? + var tracking: String? = nil enum CodingKeys: String, CodingKey { - case name + case name, tracking case parentId = "parent_id" } } +struct CategoryUpdateRequest: Codable { + let name: String? + let tracking: String? +} + struct NewShopRequest: Codable { let name: String let website: String?