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>
This commit is contained in:
128
ios/Sources/ItemListView.swift
Normal file
128
ios/Sources/ItemListView.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user