From 949c395fbf82a4f35dd9f32250532fc6bd2d625e Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 26 Jul 2026 12:48:03 +0200 Subject: [PATCH] iOS: Kategorien als ein-/ausklappbarer Baum Die Stammdaten-Kategorien waren nur eingerueckt und immer komplett offen. Jetzt lassen sich Unterkategorien per Pfeil ein- und ausklappen (wie im Web): zugeklappt zeigt die Zeile "N ausgeblendet". Anlegen, Umbenennen (Antippen) und Loeschen (wischen) bleiben unveraendert. Co-Authored-By: Claude Opus 4.8 --- ios/Sources/MasterDataViews.swift | 186 ++++++++++++++++++++++++------ 1 file changed, 151 insertions(+), 35 deletions(-) diff --git a/ios/Sources/MasterDataViews.swift b/ios/Sources/MasterDataViews.swift index dd74f59..43143a2 100644 --- a/ios/Sources/MasterDataViews.swift +++ b/ios/Sources/MasterDataViews.swift @@ -469,60 +469,176 @@ 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. struct CategoriesView: View { - var body: some View { - MasterDataListView( - title: "Kategorien", - singular: "Die Kategorie", - load: { - let alle = try await APIClient.shared.categories() - // Baumreihenfolge mit Einrueckung, damit die Verschachtelung - // sichtbar bleibt (wie in der Produktliste). - return CategoriesView.tree(alle).map { - MasterDataItem(id: $0.item.id, - title: String(repeating: " ", count: $0.depth) + $0.item.name, - subtitle: "", isBuiltin: false) - } - }, - delete: { try await APIClient.shared.deleteCategory(id: $0) } - ) { item, done in - NameEditor( - title: item == nil ? "Kategorie anlegen" : "Kategorie umbenennen", - initial: item?.title.trimmingCharacters(in: .whitespaces) ?? "", - save: { name in - if let item { - _ = try await APIClient.shared.renameCategory(id: item.id, name: name) - } else { - _ = try await APIClient.shared.createCategory( - NewCategoryRequest(name: name, parentId: nil)) - } - }, - done: done - ) - } - } + @State private var categories: [CategoryItem] = [] + @State private var collapsed: Set = [] + @State private var busy = true + @State private var error: String? + @State private var editing: CategoryItem? + @State private var addShown = false + @State private var pendingDeletion: CategoryItem? - private struct Node { + private struct Node: Identifiable { let item: CategoryItem let depth: Int + var id: Int { item.id } } - private static func tree(_ categories: [CategoryItem]) -> [Node] { + /// 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) } } - // Waisen (Oberkategorie geloescht) gelten als oberste Ebene. 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) } + } + + 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) + } + .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() + } + .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 { + categories = try await APIClient.shared.categories() + error = nil + } 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 {