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>
317 lines
12 KiB
Swift
317 lines
12 KiB
Swift
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()
|
||
}
|
||
}
|
||
}
|