From 290ff9341a229aba66ef3a4ad077877091fb043a Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 20 Oct 2026 22:07:27 +0200 Subject: [PATCH] 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/) 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 --- ios/Sources/APIClient.swift | 19 ++++ ios/Sources/ItemListView.swift | 48 ++++++++- ios/Sources/ItemViews.swift | 165 ++++++++++++++++++++++++++++++ ios/Sources/MasterDataViews.swift | 18 +++- ios/Sources/Models.swift | 97 +++++++++++++++++- 5 files changed, 343 insertions(+), 4 deletions(-) diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index 961f97f..abd8007 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -339,6 +339,25 @@ actor APIClient { try await send(try makeRequest("/items/\(id)/loans"), as: [Loan].self) } + // MARK: - Verwendungsstatus + + /// Umstellen; `status` nil heisst verfuegbar. Eigener Endpunkt aus + /// demselben Grund wie Zubehoer, und weil updateItem Admins vorbehalten ist + /// - umstellen darf jeder angemeldete Benutzer. + /// + /// Antwortet mit dem ganzen Stueck, die Aufrufstelle braucht also kein + /// zweites Laden. + func setzeVerwendung(id: Int, status: VerwendungStatus?) async throws -> Item { + var request = try makeRequest("/items/\(id)/verwendung", method: "PUT") + try jsonBody(&request, VerwendungSetzenRequest(status: status?.rawValue)) + return try await send(request, as: Item.self) + } + + /// Verwendungs-Verlauf eines Stuecks, neueste zuerst. + func itemVerwendungen(id: Int) async throws -> [Verwendung] { + try await send(try makeRequest("/items/\(id)/verwendungen"), as: [Verwendung].self) + } + /// Offene Verleihe, faelligste zuerst. `dueIn` grenzt auf die ein, deren /// Rueckgabe in so vielen Tagen ansteht (ueberfaellige sind immer dabei). func loans(dueIn: Int? = nil) async throws -> [Loan] { diff --git a/ios/Sources/ItemListView.swift b/ios/Sources/ItemListView.swift index 4de65a4..d602581 100644 --- a/ios/Sources/ItemListView.swift +++ b/ios/Sources/ItemListView.swift @@ -11,6 +11,28 @@ struct ItemListView: View { @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. @@ -97,6 +119,16 @@ struct ItemListView: View { 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() @@ -108,6 +140,7 @@ struct ItemListView: View { private var hatFilter: Bool { locationFilter != nil || categoryFilter != nil || verleihFilter != .alle + || verwendungFilter != .alle } var body: some View { @@ -193,6 +226,11 @@ struct ItemListView: View { 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 } @@ -204,7 +242,8 @@ struct ItemListView: View { if hatFilter { Divider() Button(role: .destructive) { - locationFilter = nil; categoryFilter = nil; verleihFilter = .alle + locationFilter = nil; categoryFilter = nil + verleihFilter = .alle; verwendungFilter = .alle } label: { Label("Filter zurücksetzen", systemImage: "xmark.circle") } @@ -263,6 +302,13 @@ struct ItemRow: View { .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() } diff --git a/ios/Sources/ItemViews.swift b/ios/Sources/ItemViews.swift index f409e50..122b766 100644 --- a/ios/Sources/ItemViews.swift +++ b/ios/Sources/ItemViews.swift @@ -20,6 +20,41 @@ private func anzeigeDatum(_ iso: String?) -> String { return f.string(from: d) } +/// ISO-Zeitstempel -> "20.09.2026, 18:42". Anders als bei Daten steht hier ein +/// voller Zeitpunkt: Ein Hemd wechselt mehrmals am Tag den Zustand. +func anzeigeZeitpunkt(_ iso: String?) -> String { + guard let iso else { return "–" } + guard let d = zeitpunktAus(iso) else { return iso } + let f = DateFormatter() + f.locale = Locale(identifier: "de_DE") + f.dateFormat = "dd.MM.yyyy, HH:mm" + return f.string(from: d) +} + +/// Zeitstempel des Servers lesen - mit und ohne Zeitzonenangabe. +/// +/// Postgres liefert sie mit Zone ("…+00:00"), SQLite laesst sie weg. Ohne +/// Angabe ist es UTC, denn der Server schreibt `datetime.now(UTC)`. Ohne diesen +/// Zweig stuende in der App der rohe ISO-Text. +private func zeitpunktAus(_ iso: String) -> Date? { + let mitZone = ISO8601DateFormatter() + mitZone.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let d = mitZone.date(from: iso) { return d } + mitZone.formatOptions = [.withInternetDateTime] + if let d = mitZone.date(from: iso) { return d } + + let ohneZone = DateFormatter() + ohneZone.locale = Locale(identifier: "en_US_POSIX") + ohneZone.timeZone = TimeZone(identifier: "UTC") + for muster in ["yyyy-MM-dd'T'HH:mm:ss.SSSSSS", + "yyyy-MM-dd'T'HH:mm:ss.SSS", + "yyyy-MM-dd'T'HH:mm:ss"] { + ohneZone.dateFormat = muster + if let d = ohneZone.date(from: iso) { return d } + } + return nil +} + /// Verleihzustand in einer Zeile: „Markus · zurueck bis 20.09.2026". /// Ueberfaelliges kommt beim Aufrufer rot daher. func verleihZeile(_ loan: Loan) -> String { @@ -242,6 +277,17 @@ struct ItemEditView: View { @State private var hasDue = false @State private var due = Date() @State private var busyLoan = false + // Verwendung: der laufende Zustand (nil = verfuegbar), nachgeladen wie der + // Verleih. `zeigtVerwendung` kommt vom uebergebenen Stueck und aendert sich + // waehrend der Ansicht nicht. + @State private var verwendung: VerwendungStatus? + // Was der Server zuletzt gemeldet hat. Ohne diesen Abgleich buchte das + // onChange der Leiste auch dann, wenn `verwendung` nur beim Laden gesetzt + // wurde - also bei jedem Oeffnen einmal ins Leere. + @State private var verwendungServer: VerwendungStatus? + @State private var verwendungSeit: String? + @State private var verwendungAnzahl = 0 + @State private var busyVerwendung = false var body: some View { Form { @@ -268,6 +314,39 @@ struct ItemEditView: View { } } + // Woran ist es gerade? Steht oben, weil alle Scan-Wege hier landen + // und „gerade aus der Waesche geholt" ein Griff sein soll - also + // eine Buchung wie der Verleih, kein Feld, das auf „Speichern" + // wartet. Vier Woerter waeren auf einem iPhone zu breit, vier + // Symbole passen immer. + if item.zeigtVerwendung { + Section { + Picker("Verwendung", selection: $verwendung) { + ForEach(VerwendungStatus.auswahl, id: \.self) { status in + Image(systemName: VerwendungStatus.symbol(status)) + .tag(status) + } + } + .pickerStyle(.segmented) + .disabled(busyVerwendung) + .onChange(of: verwendung) { neu in + Task { await setzeVerwendung(neu) } + } + if verwendungAnzahl > 0 { + NavigationLink("Verwendungs-Verlauf (\(verwendungAnzahl))") { + VerwendungHistorieView(uid: item.uid, itemId: item.id) + } + } + } header: { + Text("Verwendung") + } footer: { + Text(busyVerwendung + ? "Moment…" + : VerwendungStatus.label(verwendung) + + (verwendungSeit.map { " · seit \($0)" } ?? "")) + } + } + // Verleih steht vor allem anderen: Ist das Stueck draussen, ist das // die Antwort auf „wo ist es?". Alle Scan-Wege der App (QR, NFC, // vorrania://i/) landen in dieser Ansicht - deshalb genuegt der @@ -605,6 +684,42 @@ struct ItemEditView: View { } catch { self.error = error.localizedDescription } } + /// Verwendungszustand umstellen. Leer = verfuegbar. + /// + /// Die Antwort traegt das ganze Stueck, ein zweites Laden entfaellt. Nur + /// der Verlaufszaehler wird nachgeholt. + private func setzeVerwendung(_ neu: VerwendungStatus?) async { + guard neu != verwendungServer, !busyVerwendung else { return } + busyVerwendung = true; defer { busyVerwendung = false } + error = nil + do { + let frisch = try await APIClient.shared.setzeVerwendung(id: item.id, status: neu) + uebernimm(frisch.verwendung) + await ladeVerwendungen() + onChanged?() + } catch { + self.error = error.localizedDescription + // Zurueck auf den Stand des Servers, sonst zeigt die Leiste eine + // Stellung an, die nie gebucht wurde. + await ladeVerwendungen() + } + } + + private func uebernimm(_ offen: Verwendung?) { + verwendungServer = offen?.status + verwendung = offen?.status + verwendungSeit = offen.map { anzeigeZeitpunkt($0.startedAt) } + } + + /// Verlauf holen - fuer den Zaehler am Verlaufs-Eintrag und um den + /// laufenden Zustand gegen den Server abzugleichen. + private func ladeVerwendungen() async { + guard item.zeigtVerwendung else { return } + let verlauf = (try? await APIClient.shared.itemVerwendungen(id: item.id)) ?? [] + verwendungAnzahl = verlauf.count + uebernimm(verlauf.first(where: { $0.laeuft })) + } + /// Offenen Verleih und Historie frisch holen - nach jeder Buchung, damit die /// Ansicht nicht auf dem Stand des uebergebenen `item` haengen bleibt. private func ladeVerleihe() async { @@ -627,7 +742,9 @@ struct ItemEditView: View { loan = item.loan teile = item.teile gehoertZu = item.gehoertZu + uebernimm(item.verwendung) await ladeVerleihe() + await ladeVerwendungen() } private func haengeAn(_ gewaehlt: Item) async { @@ -1075,6 +1192,54 @@ struct ItemAddSheet: View { } } +// MARK: - Verwendungs-Verlauf + +/// Alle Verwendungszeitraeume eines Stuecks, neueste zuerst. Abgeschlossene +/// bleiben erhalten - so ist nachvollziehbar, seit wann etwas in der Waesche +/// liegt und wie oft es getragen wurde. +struct VerwendungHistorieView: View { + let uid: String + let itemId: Int + + @State private var zeilen: [Verwendung] = [] + @State private var geladen = false + + var body: some View { + List { + if !geladen { + Text("Wird geladen…").foregroundStyle(.secondary) + } else if zeilen.isEmpty { + Text("War noch nie in Verwendung.").foregroundStyle(.secondary) + } + ForEach(zeilen) { zeile in + VStack(alignment: .leading, spacing: 4) { + HStack { + Label(zeile.status.label, systemImage: zeile.status.symbol) + .font(.body.bold()) + Spacer() + if zeile.laeuft { + Text("läuft").font(.caption).foregroundStyle(.secondary) + } + } + Text(anzeigeZeitpunkt(zeile.startedAt) + + (zeile.endedAt.map { " – " + anzeigeZeitpunkt($0) } ?? "")) + .font(.caption).foregroundStyle(.secondary) + if let von = zeile.username { + Text("von \(von)").font(.caption).foregroundStyle(.secondary) + } + } + .padding(.vertical, 2) + } + } + .navigationTitle("Verwendung \(uid)") + .navigationBarTitleDisplayMode(.inline) + .task { + zeilen = (try? await APIClient.shared.itemVerwendungen(id: itemId)) ?? [] + geladen = true + } + } +} + // MARK: - Verleih-Verlauf /// Alle Verleihe eines Stuecks, neueste zuerst. Zurueckgegebene Vorgaenge bleiben diff --git a/ios/Sources/MasterDataViews.swift b/ios/Sources/MasterDataViews.swift index 0d40890..aec3352 100644 --- a/ios/Sources/MasterDataViews.swift +++ b/ios/Sources/MasterDataViews.swift @@ -757,6 +757,7 @@ struct CategoryEditor: View { @State private var name: String @State private var tracking: String + @State private var usageTracking: Bool @State private var parentId: Int? // nur beim Anlegen @State private var error: String? @State private var busy = false @@ -767,6 +768,7 @@ struct CategoryEditor: View { self.done = done _name = State(initialValue: item?.name ?? "") _tracking = State(initialValue: item?.tracking ?? "object") + _usageTracking = State(initialValue: item?.fuehrtVerwendung ?? false) _parentId = State(initialValue: nil) } @@ -812,6 +814,14 @@ struct CategoryEditor: View { } } + if !erbtVonEltern { + Section { + Toggle("Verwendungsstatus führen", isOn: $usageTracking) + } footer: { + Text("Einzelstücke dieser Kategorie führen, woran sie gerade sind – in Verwendung, in Reinigung, in Reparatur. Gedacht für Kleidung und Werkzeug, nicht für Vorräte. Beim Bearbeiten gilt die Einstellung auch für alle Unterkategorien.") + } + } + if let item { Section { NavigationLink { @@ -847,13 +857,17 @@ struct CategoryEditor: View { do { if let item { _ = try await APIClient.shared.updateCategory( - id: item.id, CategoryUpdateRequest(name: n, tracking: tracking)) + id: item.id, + CategoryUpdateRequest(name: n, tracking: tracking, + usageTracking: usageTracking)) } else { // Mit Oberkategorie die Art erben (tracking = nil), sonst die // gewählte Art. _ = try await APIClient.shared.createCategory( NewCategoryRequest(name: n, parentId: parentId, - tracking: parentId == nil ? tracking : nil)) + tracking: parentId == nil ? tracking : nil, + // Mit Oberkategorie die Markierung erben. + usageTracking: parentId == nil ? usageTracking : nil)) } done() dismiss() diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index 995ae30..1470616 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -1079,13 +1079,18 @@ struct CategoryItem: Codable, Identifiable, Hashable { let name: String let parentId: Int? let tracking: String? + /// Ob Einzelstuecke dieser Kategorie einen Verwendungsstatus fuehren. + /// Optional, weil ein aelterer Server das Feld nicht schickt. + var usageTracking: Bool? enum CodingKeys: String, CodingKey { case id, name, tracking case parentId = "parent_id" + case usageTracking = "usage_tracking" } var isObject: Bool { tracking == "object" } + var fuehrtVerwendung: Bool { usageTracking ?? false } } // MARK: - Hierarchische Stammdaten @@ -1266,6 +1271,73 @@ struct ItemDocument: Codable, Identifiable, Hashable { /// /// `daysOverdue` rechnet der Server, damit App und Web nicht getrennt rechnen - /// nil heisst: ohne Frist, zurueck, oder noch nicht faellig. +/// Woran ein Einzelstueck gerade ist. +/// +/// "Verfuegbar" ist bewusst kein Serverwert, sondern das Fehlen eines +/// laufenden Zeitraums - deshalb traegt der Ruhezustand hier `nil`. Absichtlich +/// allgemein und nicht auf Kleidung zugeschnitten: Eine Bohrmaschine auf der +/// Baustelle ist genauso "in Verwendung". +enum VerwendungStatus: String, Codable, CaseIterable, Identifiable, Hashable { + case inUse = "in_use" + case cleaning = "cleaning" + case repair = "repair" + + var id: String { rawValue } + + var label: String { + switch self { + case .inUse: return "In Verwendung" + case .cleaning: return "In Reinigung" + case .repair: return "In Reparatur" + } + } + + var symbol: String { + switch self { + case .inUse: return "figure.walk" + case .cleaning: return "drop" + case .repair: return "wrench.and.screwdriver" + } + } + + /// Die vier Schaltstellungen inklusive des Ruhezustands (nil). + static let auswahl: [VerwendungStatus?] = [nil] + allCases.map { Optional($0) } + + static func label(_ status: VerwendungStatus?) -> String { + status?.label ?? "Verfügbar" + } + + static func symbol(_ status: VerwendungStatus?) -> String { + status?.symbol ?? "checkmark.circle" + } +} + +/// Ein Zeitraum in einem Verwendungszustand - laufend oder abgeschlossen. +struct Verwendung: Codable, Identifiable, Hashable { + let id: Int + let itemId: Int + let status: VerwendungStatus + let startedAt: String + /// nil = laeuft noch. + let endedAt: String? + let username: String? + + var laeuft: Bool { endedAt == nil } + + enum CodingKeys: String, CodingKey { + case id, status, username + case itemId = "item_id" + case startedAt = "started_at" + case endedAt = "ended_at" + } +} + +struct VerwendungSetzenRequest: Encodable { + /// nil = verfuegbar. Muss AUCH dann mitgeschickt werden, sonst liest der + /// Server den Vorgabewert und nicht "verfuegbar". + let status: String? +} + struct Loan: Codable, Identifiable, Hashable { let id: Int let itemId: Int @@ -1391,15 +1463,28 @@ struct Item: Codable, Identifiable, Hashable { /// Swift nimmt den Vorgabewert beim Dekodieren nicht her, es wuerde werfen. var zubehoer: [ItemKurz]? var gehoertZu: ItemKurz? + /// Der laufende Verwendungszustand - nil heisst verfuegbar. Anders als beim + /// Verleih bleibt das Stueck dabei im Bestand: getragen ist nicht weg. + /// + /// Beide Felder optional, aus demselben Grund wie `zubehoer` oben: ein + /// aelterer Server schickt sie gar nicht. + var verwendung: Verwendung? + /// Ob die Kategorie dieses Stuecks ueberhaupt einen Verwendungsstatus fuehrt. + var verwendungMoeglich: Bool? /// Zubehoer ohne Optional-Gehampel an der Aufrufstelle. var teile: [ItemKurz] { zubehoer ?? [] } var istVerliehen: Bool { loan != nil } + /// Ohne Optional-Gehampel an der Aufrufstelle. + var zeigtVerwendung: Bool { verwendungMoeglich ?? false } + var verwendungStatus: VerwendungStatus? { verwendung?.status } + enum CodingKeys: String, CodingKey { - case id, uid, note, currency, documents, loan, zubehoer + case id, uid, note, currency, documents, loan, zubehoer, verwendung case gehoertZu = "gehoert_zu" + case verwendungMoeglich = "verwendung_moeglich" case productId = "product_id" case locationId = "location_id" case locationName = "location_name" @@ -1780,16 +1865,26 @@ struct NewCategoryRequest: Codable { let name: String let parentId: Int? var tracking: String? = nil + /// nil = erbt vom Elternteil bzw. neue Oberkategorie = aus. + var usageTracking: Bool? = nil enum CodingKeys: String, CodingKey { case name, tracking case parentId = "parent_id" + case usageTracking = "usage_tracking" } } struct CategoryUpdateRequest: Codable { let name: String? let tracking: String? + /// Wird vom Server in den ganzen Unterbaum uebernommen. + var usageTracking: Bool? = nil + + enum CodingKeys: String, CodingKey { + case name, tracking + case usageTracking = "usage_tracking" + } } struct NewShopRequest: Codable {