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:
co-authored by
Claude Opus 5
parent
1332dd86fa
commit
aa72d8342a
+197
-1
@@ -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))"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user