App: Einzelstuecke verleihen - alle Scan-Wege enden schon dort

Verleih-Abschnitt im Einzelstueck: an wen, seit wann, bis wann, dazu
Herausgeben bzw. Zuruecknehmen und der Verleih-Verlauf. Pflicht ist nur der
Name; Grund und Rueckgabedatum sind abschaltbar, weil "kurz mal
mitgenommen" nicht an einem Datum scheitern soll.

Der Abschnitt sitzt bewusst oben in ItemEditView, und zwar nicht nur der
Wichtigkeit wegen: QR ueber Nachschlagen, eingenaehtes NFC-Tag und ein
angetipptes Tag (vorrania://i/<UID>) landen alle in genau dieser Ansicht.
Damit ist "scannen und verleihen" ueberall moeglich, ohne einen eigenen
Weg dafuer zu bauen.

In der Einzelstueck-Liste steht der Verleih unter dem Stueck - ueberfaellig
in Rot - und das Filter-Menue kann auf Verliehen, Ueberfaellig oder Im Haus
eingrenzen. Wo etwas ist, war bisher immer der Lagerort; ist es verliehen,
ist der Entleiher die Antwort, und der Lagerort steht daneben.

days_overdue rechnet der Server (GET /loans, ItemOut.loan), damit App und
Web nicht getrennt rechnen und nicht auseinanderlaufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-17 09:36:05 +02:00
co-authored by Claude Opus 5
parent 1332dd86fa
commit aa72d8342a
7 changed files with 366 additions and 9 deletions
+3
View File
@@ -56,6 +56,9 @@ Auslagern wird automatisch die zuerst ablaufende Charge entnommen (**FEFO**).
### In der iOS-App zusätzlich ### In der iOS-App zusätzlich
- **Scannen** von Barcode, **MHD/Datum per Texterkennung** (auch nur Tag+Monat - **Scannen** von Barcode, **MHD/Datum per Texterkennung** (auch nur Tag+Monat
ohne Jahr bei Frischware), **Lagerort-QR** und **Name/Marke per Text-Scan**. ohne Jahr bei Frischware), **Lagerort-QR** und **Name/Marke per Text-Scan**.
- **Verleihen direkt nach dem Scannen** – QR, eingenähtes NFC-Tag oder
angetipptes Tag landen alle im Einzelstück, wo Herausgeben und Zurücknehmen
gleich oben stehen.
- **Foto direkt beim Anlegen** (Kamera/Galerie), **Push-Benachrichtigungen** bei - **Foto direkt beim Anlegen** (Kamera/Galerie), **Push-Benachrichtigungen** bei
ablaufenden Produkten, lokale Dashboards und Home-Screen-Schnellaktionen. ablaufenden Produkten, lokale Dashboards und Home-Screen-Schnellaktionen.
+10 -4
View File
@@ -122,10 +122,16 @@ Selfhostbare Lebensmittel-Lagerverwaltung. Aufbau in mehreren Schritten.
NFC-Tags (`/i/<uid>`) leitete vorher nur zum Artikel weiter. Jetzt zeigt es, NFC-Tags (`/i/<uid>`) leitete vorher nur zum Artikel weiter. Jetzt zeigt es,
was man in der Hand hält, und bietet Verleihen bzw. Zurücknehmen an der Stelle was man in der Hand hält, und bietet Verleihen bzw. Zurücknehmen an der Stelle
an – Gegenstück zur Lagerort-Seite (`/l/<code>`). an – Gegenstück zur Lagerort-Seite (`/l/<code>`).
- Noch offen: Oberfläche in der iOS-App sowie die **Rückgabe-Erinnerung**. Sie - **iOS-App:** Verleih-Abschnitt im Einzelstück mit Herausgeben und
kann nur den Weg der Ablauf-Meldungen gehen (lokale Benachrichtigung der App Zurücknehmen, Verleih-Verlauf, Zustand in der Einzelstück-Liste und ein Filter
aus einem Server-Snapshot, `GET /loans?due_in=…` ist dafür schon die *Verliehen / Überfällig / Im Haus*. Weil alle Scan-Wege (QR über
Datenquelle) – echtes Push fehlt weiterhin, siehe Schritt 3. *Nachschlagen*, NFC-Tag, `vorrania://i/<UID>`) in derselben Ansicht enden, ist
„scannen und verleihen" damit überall möglich, ohne einen eigenen Weg dafür.
- Noch offen: die **Rückgabe-Erinnerung**. Sie kann nur den Weg der
Ablauf-Meldungen gehen (lokale Benachrichtigung der App aus einem
Server-Snapshot, `GET /loans?due_in=…` ist dafür schon die Datenquelle) –
echtes Push fehlt weiterhin, siehe Schritt 3. Beachten: iOS erlaubt 60
geplante Meldungen je App, die sich Verleih und MHD dann teilen müssen.
## Schritt 3 – Weitere Ideen – offen ## Schritt 3 – Weitere Ideen – offen
- **Ablauf-Push-Benachrichtigungen** via APNs (setzt die App voraus). - **Ablauf-Push-Benachrichtigungen** via APNs (setzt die App voraus).
+19 -1
View File
@@ -6,7 +6,8 @@ REST-API wie die Web-Oberfläche.
> **Stand:** Voll nutzbar. Login; Scannen von Barcode, MHD/Datum (per > **Stand:** Voll nutzbar. Login; Scannen von Barcode, MHD/Datum (per
> Texterkennung, auch nur Tag+Monat ohne Jahr), Lagerort-QR und Name/Marke; > Texterkennung, auch nur Tag+Monat ohne Jahr), Lagerort-QR und Name/Marke;
> Ein-/Auslagern mit mehreren Chargen bzw. Menge je Lagerort; Einzelstücke mit > Ein-/Auslagern mit mehreren Chargen bzw. Menge je Lagerort; Einzelstücke mit
> UID/QR, Kaufdatum, Garantie und Beleg; Verbrauchsgegenstände als Charge; > UID/QR, Kaufdatum, Garantie und Beleg – auch **verleihen** und
> zurücknehmen; Verbrauchsgegenstände als Charge;
> Artikel anlegen (Open Food/Products Facts oder manuell, mit Foto); getrennte > Artikel anlegen (Open Food/Products Facts oder manuell, mit Foto); getrennte
> Listen für Lebensmittel/Gegenstände/Einzelstücke, Kategoriefilter, Sortierung; > Listen für Lebensmittel/Gegenstände/Einzelstücke, Kategoriefilter, Sortierung;
> Einkaufsliste, Ablaufliste, Mindestbestände; Verlauf mit Sprung zum Produkt; > Einkaufsliste, Ablaufliste, Mindestbestände; Verlauf mit Sprung zum Produkt;
@@ -63,6 +64,23 @@ Es gibt zwei Wege direkt in den Scan-Bildschirm:
Beide Wege öffnen die App **direkt in der Kamera**. Beide Wege öffnen die App **direkt in der Kamera**.
## Einzelstücke verleihen
In der Ansicht eines Einzelstücks steht **Verleih** ganz oben: verliehen an wen,
seit wann, bis wann – und der Knopf zum Herausgeben bzw. Zurücknehmen. Pflicht
ist nur der Name; Grund und Rückgabedatum sind optional, denn „kurz mal
mitgenommen" soll nicht an einem Datum scheitern.
Dass der Knopf genau dort sitzt, macht **scannen und verleihen** möglich, ohne
einen eigenen Weg dafür zu bauen: Alle Scan-Wege der App enden in dieser
Ansicht – der QR über *Nachschlagen*, ein eingenähtes NFC-Tag über *NFC-Tag
lesen* und ein angetipptes Tag über `vorrania://i/<UID>`.
Der Lagerort bleibt beim Verleihen stehen – dorthin gehört das Stück zurück. Als
verliehen zählt es aber nicht mehr zum Bestand, es ist ja nicht im Haus. In der
Einzelstück-Liste steht der Verleih unter dem Stück (überfällig in Rot) und lässt
sich über das Filter-Menü auf *Verliehen*, *Überfällig* oder *Im Haus* eingrenzen.
## NFC-Tags für Kleidung ## NFC-Tags für Kleidung
Ein aufgeklebtes QR-Etikett übersteht keine Wäsche. Für Kleidungsstücke gibt es Ein aufgeklebtes QR-Etikett übersteht keine Wäsche. Für Kleidungsstücke gibt es
deshalb eingenähte NFC-Tags: Einzelstück öffnen ▸ **NFC-Tag beschreiben** ▸ Tag deshalb eingenähte NFC-Tags: Einzelstück öffnen ▸ **NFC-Tag beschreiben** ▸ Tag
+28
View File
@@ -276,6 +276,34 @@ actor APIClient {
return try await send(request, as: Item.self) return try await send(request, as: Item.self)
} }
// MARK: - Verleih von Einzelstuecken
/// Herausgeben. Scheitert mit 409, wenn das Stueck schon verliehen ist.
func lendItem(id: Int, _ payload: LoanCreateRequest) async throws -> Loan {
var request = try makeRequest("/items/\(id)/loan", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: Loan.self)
}
/// Rueckgabe buchen. Leeres Datum = heute.
func returnItem(id: Int, _ payload: LoanReturnRequest = LoanReturnRequest()) async throws -> Loan {
var request = try makeRequest("/items/\(id)/return", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: Loan.self)
}
/// Verleih-Historie eines Stuecks, neueste zuerst.
func itemLoans(id: Int) async throws -> [Loan] {
try await send(try makeRequest("/items/\(id)/loans"), as: [Loan].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] {
let pfad = dueIn.map { "/loans?due_in=\($0)" } ?? "/loans"
return try await send(try makeRequest(pfad), as: [Loan].self)
}
func removeItem(id: Int, _ payload: ItemRemoveRequest) async throws { func removeItem(id: Int, _ payload: ItemRemoveRequest) async throws {
var request = try makeRequest("/items/\(id)/remove", method: "POST") var request = try makeRequest("/items/\(id)/remove", method: "POST")
try jsonBody(&request, payload) try jsonBody(&request, payload)
+41 -2
View File
@@ -10,6 +10,22 @@ struct ItemListView: View {
@State private var query = "" @State private var query = ""
@State private var locationFilter: String? // Lagerort-ID @State private var locationFilter: String? // Lagerort-ID
@State private var categoryFilter: Int? // Kategorie-ID (Unterkategorien zählen mit) @State private var categoryFilter: Int? // Kategorie-ID (Unterkategorien zählen mit)
@State private var verleihFilter: VerleihFilter = .alle
/// 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 busy = true
@State private var error: String? @State private var error: String?
@@ -73,6 +89,12 @@ struct ItemListView: View {
return items.filter { it in return items.filter { it in
if let cat = categoryFilter, !imTeilbaum(it.categoryId, target: cat) { return false } if let cat = categoryFilter, !imTeilbaum(it.categoryId, target: cat) { return false }
if let loc = locationFilter, it.locationId != loc { 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 }
}
if !q.isEmpty { if !q.isEmpty {
let heu = [it.productName, it.uid, it.note, it.productBrand] let heu = [it.productName, it.uid, it.note, it.productBrand]
.compactMap { $0 }.joined(separator: " ").lowercased() .compactMap { $0 }.joined(separator: " ").lowercased()
@@ -82,7 +104,9 @@ struct ItemListView: View {
} }
} }
private var hatFilter: Bool { locationFilter != nil || categoryFilter != nil } private var hatFilter: Bool {
locationFilter != nil || categoryFilter != nil || verleihFilter != .alle
}
var body: some View { var body: some View {
List { List {
@@ -117,6 +141,11 @@ struct ItemListView: View {
check(loc.path(in: locations), locationFilter == loc.id) { locationFilter = loc.id } 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 }
}
}
if !filterKategorien.isEmpty { if !filterKategorien.isEmpty {
Menu("Kategorie") { Menu("Kategorie") {
check("Alle", categoryFilter == nil) { categoryFilter = nil } check("Alle", categoryFilter == nil) { categoryFilter = nil }
@@ -127,7 +156,9 @@ struct ItemListView: View {
} }
if hatFilter { if hatFilter {
Divider() Divider()
Button(role: .destructive) { locationFilter = nil; categoryFilter = nil } label: { Button(role: .destructive) {
locationFilter = nil; categoryFilter = nil; verleihFilter = .alle
} label: {
Label("Filter zurücksetzen", systemImage: "xmark.circle") Label("Filter zurücksetzen", systemImage: "xmark.circle")
} }
} }
@@ -175,6 +206,14 @@ struct ItemRow: View {
Text("· \(pfad)").font(.caption).foregroundStyle(.secondary).lineLimit(1) 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)
}
} }
Spacer() Spacer()
} }
+197 -1
View File
@@ -9,6 +9,29 @@ private func dateToString(_ d: Date) -> String {
private func stringToDate(_ s: String) -> Date? { private func stringToDate(_ s: String) -> Date? {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.date(from: s) let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.date(from: s)
} }
/// "2026-09-20" -> "20.09.2026". Faellt auf den Rohwert zurueck, damit nie
/// eine leere Zeile steht.
private func anzeigeDatum(_ iso: String?) -> String {
guard let iso else { return "–" }
guard let d = stringToDate(iso) else { return iso }
let f = DateFormatter()
f.locale = Locale(identifier: "de_DE")
f.dateFormat = "dd.MM.yyyy"
return f.string(from: d)
}
/// Verleihzustand in einer Zeile: „Markus · zurueck bis 20.09.2026".
/// Ueberfaelliges kommt beim Aufrufer rot daher.
func verleihZeile(_ loan: Loan) -> String {
var teile = [loan.borrower]
if let tage = loan.daysOverdue {
teile.append("überfällig seit \(tage) \(tage == 1 ? "Tag" : "Tagen")")
} else if let due = loan.dueOn {
teile.append("zurück bis \(anzeigeDatum(due))")
}
if let grund = loan.reason, !grund.isEmpty { teile.append(grund) }
return teile.joined(separator: " · ")
}
/// Datumszeile mit einheitlichem Format (dd.MM.yyyy). Die native compact-DatePicker /// Datumszeile mit einheitlichem Format (dd.MM.yyyy). Die native compact-DatePicker
/// zeigt das Jahr je nach Feld mal zwei-, mal vierstellig – deshalb hier ein fester /// zeigt das Jahr je nach Feld mal zwei-, mal vierstellig – deshalb hier ein fester
@@ -88,7 +111,12 @@ struct ProductItemsSection: View {
} }
private func untertitel(_ it: Item) -> String { private func untertitel(_ it: Item) -> String {
[it.locationName, it.acquiredOn.map { "gekauft \($0)" }, it.shopName] // Ist das Stueck draussen, ist das die wichtigste Angabe - der Lagerort
// steht daneben, weil es dorthin zurueckgehoert.
if let loan = it.loan {
return "verliehen an \(verleihZeile(loan))"
}
return [it.locationName, it.acquiredOn.map { "gekauft \($0)" }, it.shopName]
.compactMap { $0 }.joined(separator: " · ") .compactMap { $0 }.joined(separator: " · ")
} }
private func reload() async { private func reload() async {
@@ -195,6 +223,15 @@ struct ItemEditView: View {
@State private var previewURL: URL? @State private var previewURL: URL?
@State private var locScanShown = false @State private var locScanShown = false
@State private var nfcShown = false @State private var nfcShown = false
// Verleih: der offene Vorgang (nil = im Haus) und die Eingaben dafuer.
@State private var loan: Loan?
@State private var loanHistory: [Loan] = []
@State private var showLend = false
@State private var borrower = ""
@State private var loanReason = ""
@State private var hasDue = false
@State private var due = Date()
@State private var busyLoan = false
var body: some View { var body: some View {
Form { Form {
@@ -221,6 +258,44 @@ struct ItemEditView: View {
} }
} }
// 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
// Knopf hier, um „scannen und verleihen" moeglich zu machen.
Section("Verleih") {
if let loan {
LabeledContent("Verliehen an", value: loan.borrower)
if let grund = loan.reason, !grund.isEmpty {
LabeledContent("Grund", value: grund)
}
LabeledContent("Seit", value: anzeigeDatum(loan.lentOn))
if let tage = loan.daysOverdue {
LabeledContent("Zurück bis") {
Text("\(anzeigeDatum(loan.dueOn)) · überfällig seit "
+ "\(tage) \(tage == 1 ? "Tag" : "Tagen")")
.foregroundStyle(.red)
}
} else {
LabeledContent("Zurück bis",
value: loan.dueOn == nil ? "offen" : anzeigeDatum(loan.dueOn))
}
Button { Task { await nimmZurueck() } } label: {
Label(busyLoan ? "Moment…" : "Zurücknehmen", systemImage: "arrow.uturn.down")
}
.disabled(busyLoan)
} else {
Text("Im Haus.").foregroundStyle(.secondary)
Button { starteVerleih() } label: {
Label("Verleihen", systemImage: "hand.raised")
}
}
if !loanHistory.isEmpty {
NavigationLink("Verleih-Verlauf (\(loanHistory.count))") {
LoanHistoryView(uid: item.uid, loans: loanHistory)
}
}
}
Section { Section {
Picker("Lagerort", selection: $locationId) { Picker("Lagerort", selection: $locationId) {
Text("– ohne –").tag(String?.none) Text("– ohne –").tag(String?.none)
@@ -328,6 +403,9 @@ struct ItemEditView: View {
.sheet(isPresented: $showRemove) { .sheet(isPresented: $showRemove) {
NavigationStack { removeSheet } NavigationStack { removeSheet }
} }
.sheet(isPresented: $showLend) {
NavigationStack { lendSheet }
}
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.pdf]) { result in .fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.pdf]) { result in
if case .success(let url) = result { if case .success(let url) = result {
Task { Task {
@@ -354,6 +432,32 @@ struct ItemEditView: View {
.quickLookPreview($previewURL) .quickLookPreview($previewURL)
} }
/// Herausgeben: Nur der Name ist Pflicht. Das Rueckgabedatum ist bewusst
/// abschaltbar - „kurz mal mitgenommen" soll nicht an einem Datum scheitern.
private var lendSheet: some View {
Form {
Section {
LabeledField(label: "Verliehen an", text: $borrower)
LabeledField(label: "Grund (optional)", text: $loanReason)
Toggle("Rückgabe bis", isOn: $hasDue)
if hasDue { DateRow(label: "Zurück bis", date: $due) }
} footer: {
Text("Der Lagerort bleibt stehen – dorthin gehört das Stück zurück. "
+ "Als verliehen zählt es aber nicht mehr zum Bestand.")
}
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
Section {
Button(busyLoan ? "Moment…" : "Verleihen") { Task { await verleihe() } }
.disabled(busyLoan || borrower.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.navigationTitle("Verleihen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { showLend = false } }
}
}
private var removeSheet: some View { private var removeSheet: some View {
Form { Form {
Picker("Grund", selection: $reason) { Picker("Grund", selection: $reason) {
@@ -369,6 +473,47 @@ struct ItemEditView: View {
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { showRemove = false } } } .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { showRemove = false } } }
} }
// MARK: - Verleih
private func starteVerleih() {
borrower = ""
loanReason = ""
hasDue = false
due = Date()
error = nil
showLend = true
}
private func verleihe() async {
busyLoan = true; defer { busyLoan = false }; error = nil
do {
loan = try await APIClient.shared.lendItem(id: item.id, LoanCreateRequest(
borrower: borrower.trimmingCharacters(in: .whitespaces),
reason: loanReason.isEmpty ? nil : loanReason,
dueOn: hasDue ? dateToString(due) : nil))
showLend = false
await ladeVerleihe()
onChanged?()
} catch { self.error = error.localizedDescription }
}
private func nimmZurueck() async {
busyLoan = true; defer { busyLoan = false }; error = nil
do {
_ = try await APIClient.shared.returnItem(id: item.id)
loan = nil
await ladeVerleihe()
onChanged?()
} catch { self.error = error.localizedDescription }
}
/// 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 {
loanHistory = (try? await APIClient.shared.itemLoans(id: item.id)) ?? []
loan = loanHistory.first(where: { $0.istOffen })
}
private func load() async { private func load() async {
locations = (try? await APIClient.shared.locations()) ?? [] locations = (try? await APIClient.shared.locations()) ?? []
shops = (try? await APIClient.shared.shops()) ?? [] shops = (try? await APIClient.shared.shops()) ?? []
@@ -381,6 +526,8 @@ struct ItemEditView: View {
if let p = item.priceCents { priceText = ItemEditView.formatCents(p) } if let p = item.priceCents { priceText = ItemEditView.formatCents(p) }
if let s = item.acquiredOn, let d = stringToDate(s) { acquired = d; hasAcquired = true } if let s = item.acquiredOn, let d = stringToDate(s) { acquired = d; hasAcquired = true }
if let s = item.warrantyUntil, let d = stringToDate(s) { warranty = d; hasWarranty = true } if let s = item.warrantyUntil, let d = stringToDate(s) { warranty = d; hasWarranty = true }
loan = item.loan
await ladeVerleihe()
} }
private func save() async { private func save() async {
@@ -677,3 +824,52 @@ struct ItemAddSheet: View {
} catch { self.error = error.localizedDescription } } catch { self.error = error.localizedDescription }
} }
} }
// MARK: - Verleih-Verlauf
/// Alle Verleihe eines Stuecks, neueste zuerst. Zurueckgegebene Vorgaenge bleiben
/// erhalten - so ist nachvollziehbar, wer das Stueck wann hatte.
struct LoanHistoryView: View {
let uid: String
let loans: [Loan]
var body: some View {
List {
ForEach(loans) { loan in
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(loan.borrower).bold()
Spacer()
if loan.istOffen {
Text(loan.istUeberfaellig ? "überfällig" : "offen")
.font(.caption)
.foregroundStyle(loan.istUeberfaellig ? Color.red : .secondary)
}
}
Text(zeitraum(loan)).font(.caption).foregroundStyle(.secondary)
if let grund = loan.reason, !grund.isEmpty {
Text(grund).font(.caption).foregroundStyle(.secondary)
}
if let von = loan.username {
Text("herausgegeben von \(von)").font(.caption2).foregroundStyle(.secondary)
}
}
}
if loans.isEmpty {
Text("Noch nie verliehen.").foregroundStyle(.secondary)
}
}
.navigationTitle("Verlauf \(uid)")
.navigationBarTitleDisplayMode(.inline)
}
private func zeitraum(_ loan: Loan) -> String {
let von = anzeigeDatum(loan.lentOn)
guard let zurueck = loan.returnedOn else {
return loan.dueOn == nil
? "seit \(von)"
: "seit \(von) · zurück bis \(anzeigeDatum(loan.dueOn))"
}
return "\(von) – \(anzeigeDatum(zurueck))"
}
}
+68 -1
View File
@@ -1046,6 +1046,68 @@ struct ItemDocument: Codable, Identifiable, Hashable {
} }
} }
/// Ein Verleih-Vorgang eines Einzelstuecks. `returnedOn == nil` heisst „noch
/// draussen"; je Stueck gibt es hoechstens einen solchen offenen Verleih.
///
/// `daysOverdue` rechnet der Server, damit App und Web nicht getrennt rechnen -
/// nil heisst: ohne Frist, zurueck, oder noch nicht faellig.
struct Loan: Codable, Identifiable, Hashable {
let id: Int
let itemId: Int
let borrower: String
let reason: String?
let lentOn: String // "yyyy-MM-dd"
let dueOn: String?
let returnedOn: String?
let createdAt: String
let username: String?
let daysOverdue: Int?
let itemUid: String?
let productId: Int?
let productName: String?
let productBrand: String?
let locationId: String?
let locationName: String?
var istOffen: Bool { returnedOn == nil }
var istUeberfaellig: Bool { (daysOverdue ?? 0) > 0 }
enum CodingKeys: String, CodingKey {
case id, borrower, reason, username
case itemId = "item_id"
case lentOn = "lent_on"
case dueOn = "due_on"
case returnedOn = "returned_on"
case createdAt = "created_at"
case daysOverdue = "days_overdue"
case itemUid = "item_uid"
case productId = "product_id"
case productName = "product_name"
case productBrand = "product_brand"
case locationId = "location_id"
case locationName = "location_name"
}
}
struct LoanCreateRequest: Encodable {
let borrower: String
var reason: String? = nil
var lentOn: String? = nil
var dueOn: String? = nil
enum CodingKeys: String, CodingKey {
case borrower, reason
case lentOn = "lent_on"
case dueOn = "due_on"
}
}
struct LoanReturnRequest: Encodable {
var returnedOn: String? = nil
enum CodingKeys: String, CodingKey { case returnedOn = "returned_on" }
}
struct Item: Codable, Identifiable, Hashable { struct Item: Codable, Identifiable, Hashable {
let id: Int let id: Int
let uid: String let uid: String
@@ -1065,9 +1127,14 @@ struct Item: Codable, Identifiable, Hashable {
let categoryId: Int? let categoryId: Int?
let categoryName: String? let categoryName: String?
let documents: [ItemDocument] let documents: [ItemDocument]
/// Der offene Verleih, falls das Stueck gerade draussen ist - sonst nil.
/// Der Lagerort steht daneben unveraendert: dorthin gehoert es zurueck.
let loan: Loan?
var istVerliehen: Bool { loan != nil }
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, uid, note, currency, documents case id, uid, note, currency, documents, loan
case productId = "product_id" case productId = "product_id"
case locationId = "location_id" case locationId = "location_id"
case locationName = "location_name" case locationName = "location_name"