import SwiftUI /// Liste aller Einzelstücke (physische Exemplare) über alle Produkte – anders als /// die Produktliste, die nur die Katalog-Typen zeigt. Such- und Filterfunktion, /// v.a. nach Lagerort. Tippen öffnet das Einzelstück. struct ItemListView: View { @State private var items: [Item] = [] @State private var locations: [StorageLocation] = [] @State private var categories: [CategoryItem] = [] @State private var query = "" @State private var locationFilter: String? // Lagerort-ID @State private var categoryFilter: Int? // Kategorie-ID (Unterkategorien zählen mit) @State private var busy = true @State private var error: String? private struct TreeEntry: Identifiable { let item: CategoryItem let depth: Int var id: Int { item.id } var label: String { String(repeating: " ", count: depth) + item.name } } private var catById: [Int: CategoryItem] { Dictionary(categories.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) } /// Ist `catId` die Kategorie `target` oder eine ihrer Unterkategorien? private func imTeilbaum(_ catId: Int?, target: Int) -> Bool { let map = catById var cur = catId var g = 0 while let c = cur, g < 50 { if c == target { return true } cur = map[c]?.parentId g += 1 } return false } /// Kategorien fürs Filter-Menü: alle, die selbst oder als Oberkategorie zu einem /// vorhandenen Einzelstück gehören – so ist z.B. auch „Klamotten" wählbar. Baum /// mit eingerückter Beschriftung (hierarchisch). private var filterKategorien: [TreeEntry] { let map = catById var relevant = Set() for it in items { var cur = it.categoryId var g = 0 while let c = cur, !relevant.contains(c), g < 50 { relevant.insert(c) cur = map[c]?.parentId g += 1 } } let liste = categories.filter { relevant.contains($0.id) } let ids = Set(liste.map(\.id)) var result: [TreeEntry] = [] func walk(_ node: CategoryItem, _ depth: Int) { result.append(TreeEntry(item: node, depth: depth)) for child in liste.filter({ $0.parentId == node.id }) { walk(child, depth + 1) } } for root in liste.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) { walk(root, 0) } return result } private var filterKategorieName: String { if let id = categoryFilter, let t = categories.first(where: { $0.id == id }) { return t.name } return "Alle" } private var gefiltert: [Item] { let q = query.trimmingCharacters(in: .whitespaces).lowercased() return items.filter { it in if let cat = categoryFilter, !imTeilbaum(it.categoryId, target: cat) { return false } if let loc = locationFilter, it.locationId != loc { return false } if !q.isEmpty { let heu = [it.productName, it.uid, it.note, it.productBrand] .compactMap { $0 }.joined(separator: " ").lowercased() if !heu.contains(q) { return false } } return true } } private var hatFilter: Bool { locationFilter != nil || categoryFilter != nil } var body: some View { List { if !busy && gefiltert.isEmpty { Text(items.isEmpty ? "Noch keine Einzelstücke." : "Nichts gefunden.") .foregroundStyle(.secondary) } ForEach(gefiltert) { it in NavigationLink { ItemEditView(item: it) } label: { ItemRow(item: it, locations: locations) } } } .searchable(text: $query, prompt: "Produkt, UID oder Notiz") .navigationTitle("Einzelstücke") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { filterMenu } } .overlay { if busy && items.isEmpty { ProgressView() } } .refreshable { await load() } // Auch beim Zurückkommen aus dem Einzelstück neu laden (z.B. Lagerort geändert). .onAppear { Task { await load() } } .alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) { Button("OK", role: .cancel) {} } message: { Text(error ?? "") } } private var filterMenu: some View { Menu { Menu("Lagerort") { check("Alle", locationFilter == nil) { locationFilter = nil } ForEach(locations) { loc in check(loc.path(in: locations), locationFilter == loc.id) { locationFilter = loc.id } } } if !filterKategorien.isEmpty { Menu("Kategorie") { check("Alle", categoryFilter == nil) { categoryFilter = nil } ForEach(filterKategorien) { e in check(e.label, categoryFilter == e.item.id) { categoryFilter = e.item.id } } } } if hatFilter { Divider() Button(role: .destructive) { locationFilter = nil; categoryFilter = nil } label: { Label("Filter zurücksetzen", systemImage: "xmark.circle") } } } label: { Image(systemName: hatFilter ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") } } @ViewBuilder private func check(_ title: String, _ selected: Bool, _ action: @escaping () -> Void) -> some View { Button(action: action) { if selected { Label(title, systemImage: "checkmark") } else { Text(title) } } } private func load() async { busy = true defer { busy = false } do { items = try await APIClient.shared.allItems() locations = (try? await APIClient.shared.locations()) ?? [] categories = (try? await APIClient.shared.categories()) ?? [] } catch { self.error = error.localizedDescription } } } /// Eine Einzelstück-Zeile: Produktbild, Produktname, UID und Lagerort-Pfad. struct ItemRow: View { let item: Item let locations: [StorageLocation] var body: some View { HStack(spacing: 12) { ProductThumb(productId: item.productId) VStack(alignment: .leading, spacing: 2) { Text(item.productName ?? "—") let pfad = locationPath(item.locationId, in: locations) HStack(spacing: 6) { Text(item.uid).font(.caption).foregroundStyle(.secondary) if !pfad.isEmpty { Text("· \(pfad)").font(.caption).foregroundStyle(.secondary).lineLimit(1) } } } Spacer() } } }