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>
This commit is contained in:
Scarriffle
2026-10-20 22:07:27 +02:00
co-authored by Claude Opus 5
parent b9d097fe96
commit 290ff9341a
5 changed files with 343 additions and 4 deletions
+19
View File
@@ -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] {
+47 -1
View File
@@ -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()
}
+165
View File
@@ -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/<UID>) 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
+16 -2
View File
@@ -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()
+96 -1
View File
@@ -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 {