Files
Vorrania/ios/Sources/ItemListView.swift
Scarriffle 1d173bb9c0 iOS: Listen beim Zurueckkommen neu laden (Bestand nicht mehr veraltet)
Nach dem Anlegen eines Einzelstuecks im Detail zeigte die Produktliste weiter
"Bestand 0", weil sie nicht aktualisiert wurde (das Stueck war aber gespeichert).
Produkt- und Einzelstueckliste laden jetzt bei jedem Erscheinen neu (onAppear),
auch beim Zurueckkommen aus der Detailansicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 16:31:32 +02:00

183 lines
7.0 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() }
// 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()
}
}
}