Files
Vorrania/ios/Sources/ItemListView.swift
Scarriffle 532695c2ee iOS: Einzelstueck-Kategoriefilter hierarchisch + mit Oberkategorien
Der Kategoriefilter der Einzelstueckliste zeigt jetzt einen eingerueckten Baum
und enthaelt auch die Oberkategorien (z.B. "Klamotten"), nicht nur die Blaetter
mit Exemplaren. Nach einer Oberkategorie zu filtern schliesst alle Unter-
kategorien ein (ueber category_id statt Namensgleichheit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 13:45:41 +02:00

182 lines
6.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Int>()
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() }
.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()
}
}
}