Files
Vorrania/ios/Sources/ItemListView.swift
T
ScarriffleandClaude Opus 5 290ff9341a Verwendungsstatus: in der App umstellen, filtern und nachsehen
Im Einzelstueck steht jetzt ueber dem Verleih ein Abschnitt "Verwendung"
mit vier Symbolen: verfuegbar, in Verwendung, in Reinigung, in Reparatur.
Ein Tipp bucht sofort, ohne "Speichern" - wie beim Verleih, denn alle
Scan-Wege (QR, NFC, vorrania://i/<UID>) landen genau in dieser Ansicht.
Vier Woerter waeren auf einem iPhone zu breit, vier Symbole passen immer;
darunter steht der Name im Klartext samt "seit wann".

Dazu der Verwendungs-Verlauf mit Zeitstempeln und Konto, und in der
Einzelstueck-Liste ein Filter ueber alle vier Zustaende plus die Zeile
unter dem Namen, solange etwas laeuft. Stuecke ohne Verwendungsstatus
fallen aus dem Filter "Verfuegbar" heraus - ein Schraubenzieher ist nicht
verfuegbar, er fuehrt den Zustand gar nicht.

Der Haken je Kategorie sitzt im Kategorie-Editor und gilt beim Bearbeiten
auch fuer die Unterkategorien.

Zwei Dinge, die beim Pruefen am Simulator auffielen: Die Leiste haette
beim Oeffnen jedes Mal einmal ins Leere gebucht, weil onChange auch beim
Laden feuert - dagegen der Abgleich mit dem zuletzt gemeldeten Stand. Und
SQLite liefert Zeitstempel ohne Zeitzone, woran ISO8601DateFormatter
scheitert; in der App stand deshalb der rohe ISO-Text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-20 22:07:27 +02:00

317 lines
12 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 verleihFilter: VerleihFilter = .alle
@State private var verwendungFilter: VerwendungFilter = .alle
/// Blickwinkel auf den Verwendungszustand – „was liegt gerade in der
/// Wäsche?" ist die Frage dahinter. „Verfügbar" ist dabei das Fehlen eines
/// laufenden Zeitraums, kein eigener Serverwert.
enum VerwendungFilter: Hashable, CaseIterable {
case alle
case verfuegbar
case status(VerwendungStatus)
static var allCases: [VerwendungFilter] {
[.alle, .verfuegbar] + VerwendungStatus.allCases.map { .status($0) }
}
var label: String {
switch self {
case .alle: return "Alle"
case .verfuegbar: return "Verfügbar"
case let .status(s): return s.label
}
}
}
/// Blickwinkel auf den Verleihzustand – „was ist gerade draußen?" ist die
/// Frage, die man im Alltag an diese Liste stellt.
enum VerleihFilter: String, CaseIterable {
case alle, verliehen, ueberfaellig, imHaus
var label: String {
switch self {
case .alle: return "Alle"
case .verliehen: return "Verliehen"
case .ueberfaellig: return "Überfällig"
case .imHaus: return "Im Haus"
}
}
}
@State private var busy = true
@State private var error: String?
/// Stück, dessen Rückgabe gerade bestätigt werden soll.
@State private var zurueckFuer: Item?
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 }
switch verleihFilter {
case .alle: break
case .verliehen: if it.loan == nil { return false }
case .ueberfaellig: if !(it.loan?.istUeberfaellig ?? false) { return false }
case .imHaus: if it.loan != nil { return false }
}
switch verwendungFilter {
case .alle: break
// Stuecke ohne Verwendungsstatus fallen hier bewusst raus: Ein
// Schraubenzieher ist nicht „verfuegbar", er fuehrt den Zustand gar
// nicht.
case .verfuegbar:
if !it.zeigtVerwendung || it.verwendungStatus != nil { return false }
case let .status(gesucht):
if it.verwendungStatus != gesucht { 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 || verleihFilter != .alle
|| verwendungFilter != .alle
}
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)
}
// Zuruecknehmen ohne Umweg: Bisher fuehrte der einzige Weg ueber
// das Bearbeiten-Formular des Stuecks.
.swipeActions(edge: .leading) {
if it.istVerliehen {
Button { zurueckFuer = it } label: {
Label("Zurücknehmen", systemImage: "arrow.uturn.down")
}
.tint(.green)
}
}
.contextMenu {
if it.istVerliehen {
Button { zurueckFuer = it } label: {
Label("Zurücknehmen", systemImage: "arrow.uturn.down")
}
}
}
}
}
.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() } }
.confirmationDialog(
zurueckFuer.map { "„\($0.uid)“ zurücknehmen?" } ?? "",
isPresented: Binding(get: { zurueckFuer != nil },
set: { if !$0 { zurueckFuer = nil } }),
titleVisibility: .visible
) {
Button("Zurücknehmen") { Task { await nimmZurueck() } }
Button("Abbrechen", role: .cancel) { zurueckFuer = nil }
} message: {
if let it = zurueckFuer, let l = it.loan {
let mit = it.teile.filter(\.verliehen)
Text("Von \(l.borrower)." + (mit.isEmpty ? "" :
" Das mitverliehene Zubehör kommt mit zurück: "
+ mit.map(\.uid).joined(separator: ", ") + "."))
}
}
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("OK", role: .cancel) {}
} message: { Text(error ?? "") }
}
private func nimmZurueck() async {
guard let it = zurueckFuer else { return }
zurueckFuer = nil
do {
_ = try await APIClient.shared.returnItem(
id: it.id,
LoanReturnRequest(mitZubehoer: it.teile.filter(\.verliehen).map(\.id))
)
await load()
} catch { self.error = error.localizedDescription }
}
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 }
}
}
Menu("Verleih") {
ForEach(VerleihFilter.allCases, id: \.self) { f in
check(f.label, verleihFilter == f) { verleihFilter = f }
}
}
Menu("Verwendung") {
ForEach(VerwendungFilter.allCases, id: \.self) { f in
check(f.label, verwendungFilter == f) { verwendungFilter = f }
}
}
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
verleihFilter = .alle; verwendungFilter = .alle
} label: {
Label("Filter zurücksetzen", systemImage: "xmark.circle")
}
}
} label: {
Label(hatFilter ? verleihFilter.label : "Filter",
systemImage: hatFilter
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
.labelStyle(.titleAndIcon)
}
}
@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)
}
}
// Verliehen ist die Antwort auf „wo ist es?" - deshalb eigene
// Zeile, ueberfaellig in Rot.
if let loan = item.loan {
Text("verliehen an \(verleihZeile(loan))")
.font(.caption)
.foregroundStyle(loan.istUeberfaellig ? Color.red : .secondary)
.lineLimit(2)
}
// Nur, wenn etwas laeuft: „Verfuegbar" unter jeder zweiten
// Zeile waere Rauschen.
if let status = item.verwendungStatus {
Label(status.label, systemImage: status.symbol)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
}
}
}