Files
Vorrania/ios/Sources/ItemListView.swift
Scarriffle 6b7ada33b3 Einzelstueck-Liste: Web-Seite /items + iOS-Liste (filterbar nach Lagerort)
Neue Seite 'Einzelstuecke' (Web, /items, in der Sidebar) listet alle physischen Exemplare ueber alle Produkte in der DataTable - je Spalte filterbar (Lagerort als Pfad, Produkt, Kategorie, Shop, ...), Lagerort und Shop inline aenderbar. iOS bekommt dieselbe Liste als schlichte, filterbare Ansicht (Kachel unter 'Listen'): Suche + Lagerort-/Kategorie-Filter, Tippen oeffnet das Einzelstueck. Item-Model/ItemOut um Kategorie ergaenzt; neuer APIClient.allItems().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 14:58:03 +02:00

129 lines
4.8 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 query = ""
@State private var locationFilter: String? // Lagerort-ID
@State private var categoryFilter: String? // Kategoriename
@State private var busy = true
@State private var error: String?
private var categories: [String] {
Array(Set(items.compactMap { $0.categoryName })).sorted()
}
private var gefiltert: [Item] {
let q = query.trimmingCharacters(in: .whitespaces).lowercased()
return items.filter { it in
if let cat = categoryFilter, it.categoryName != 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 !categories.isEmpty {
Menu("Kategorie") {
check("Alle", categoryFilter == nil) { categoryFilter = nil }
ForEach(categories, id: \.self) { c in
check(c, categoryFilter == c) { categoryFilter = c }
}
}
}
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()) ?? []
} 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()
}
}
}