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
+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
> Texterkennung, auch nur Tag+Monat ohne Jahr), Lagerort-QR und Name/Marke;
> 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
> Listen für Lebensmittel/Gegenstände/Einzelstücke, Kategoriefilter, Sortierung;
> 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**.
## 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
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
+28
View File
@@ -276,6 +276,34 @@ actor APIClient {
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 {
var request = try makeRequest("/items/\(id)/remove", method: "POST")
try jsonBody(&request, payload)
+41 -2
View File
@@ -10,6 +10,22 @@ struct ItemListView: View {
@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
/// 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?
@@ -73,6 +89,12 @@ struct ItemListView: View {
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 }
}
if !q.isEmpty {
let heu = [it.productName, it.uid, it.note, it.productBrand]
.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 {
List {
@@ -117,6 +141,11 @@ struct ItemListView: View {
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 {
Menu("Kategorie") {
check("Alle", categoryFilter == nil) { categoryFilter = nil }
@@ -127,7 +156,9 @@ struct ItemListView: View {
}
if hatFilter {
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")
}
}
@@ -175,6 +206,14 @@ struct ItemRow: View {
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()
}
+197 -1
View File
@@ -9,6 +9,29 @@ private func dateToString(_ d: Date) -> String {
private func stringToDate(_ s: String) -> Date? {
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
/// 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 {
[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: " · ")
}
private func reload() async {
@@ -195,6 +223,15 @@ struct ItemEditView: View {
@State private var previewURL: URL?
@State private var locScanShown = 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 {
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 {
Picker("Lagerort", selection: $locationId) {
Text("– ohne –").tag(String?.none)
@@ -328,6 +403,9 @@ struct ItemEditView: View {
.sheet(isPresented: $showRemove) {
NavigationStack { removeSheet }
}
.sheet(isPresented: $showLend) {
NavigationStack { lendSheet }
}
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.pdf]) { result in
if case .success(let url) = result {
Task {
@@ -354,6 +432,32 @@ struct ItemEditView: View {
.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 {
Form {
Picker("Grund", selection: $reason) {
@@ -369,6 +473,47 @@ struct ItemEditView: View {
.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 {
locations = (try? await APIClient.shared.locations()) ?? []
shops = (try? await APIClient.shared.shops()) ?? []
@@ -381,6 +526,8 @@ struct ItemEditView: View {
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.warrantyUntil, let d = stringToDate(s) { warranty = d; hasWarranty = true }
loan = item.loan
await ladeVerleihe()
}
private func save() async {
@@ -677,3 +824,52 @@ struct ItemAddSheet: View {
} 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 {
let id: Int
let uid: String
@@ -1065,9 +1127,14 @@ struct Item: Codable, Identifiable, Hashable {
let categoryId: Int?
let categoryName: String?
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 {
case id, uid, note, currency, documents
case id, uid, note, currency, documents, loan
case productId = "product_id"
case locationId = "location_id"
case locationName = "location_name"