Files
Vorrania/ios/Sources/ItemViews.swift
T
ScarriffleandClaude Opus 5 290ff9341a 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>
2026-10-20 22:07:27 +02:00

1291 lines
55 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import PhotosUI
import QuickLook
import UniformTypeIdentifiers
private func dateToString(_ d: Date) -> String {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.string(from: d)
}
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)
}
/// 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 {
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
/// Formatierer und ein grafischer Picker im Popover.
struct DateRow: View {
let label: String
@Binding var date: Date
@State private var open = false
private static let fmt: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "de_DE")
f.dateFormat = "dd.MM.yyyy"
return f
}()
var body: some View {
Group {
Button { withAnimation(.easeInOut(duration: 0.15)) { open.toggle() } } label: {
HStack {
Text(label).foregroundStyle(.primary)
Spacer()
Text(Self.fmt.string(from: date))
.foregroundStyle(open ? Color.marke : .secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if open {
DatePicker(label, selection: $date, displayedComponents: .date)
.datePickerStyle(.graphical)
.labelsHidden()
}
}
}
}
// MARK: - Liste (in der Produkt-Detailansicht)
/// Einzelstücke eines Gegenstands: Liste mit Anlegen; jedes Stück öffnet die
/// Detail-/Bearbeiten-Ansicht.
struct ProductItemsSection: View {
let product: Product
var onChanged: () async -> Void
/// Bis zur Liste schließen (für „Anlegen & fertig").
var onFinish: (() -> Void)? = nil
/// Hochzaehlen laedt die Liste neu. Noetig, seit das Anlege-Blatt oben
/// haengt: Von dort kommt kein Rueckruf mehr in diesen Abschnitt.
var nachladen: Int = 0
/// Oeffnet das Anlege-Blatt. Die Bindung gehoert der Elternansicht - das
/// Blatt wird dort gezeigt, nicht hier (siehe unten).
@Binding var anlegen: Bool
@State private var items: [Item] = []
var body: some View {
Section("Einzelstücke (\(items.count))") {
ForEach(items) { it in
NavigationLink {
ItemEditView(item: it) { Task { await refresh() } }
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(it.uid).font(.body.monospaced()).bold()
Text(untertitel(it)).font(.caption).foregroundStyle(.secondary)
}
}
}
if items.isEmpty {
Text("Noch keine Einzelstücke.").foregroundStyle(.secondary)
}
Button { anlegen = true } label: {
Label("Einzelstücke anlegen", systemImage: "plus")
}
}
.task(id: "\(product.id)-\(nachladen)") { await reload() }
// Kein .sheet hier. Aus einem Abschnitt heraus gezeigt, schloss es sich
// beim ERSTEN Oeffnen sofort wieder und hielt erst beim zweiten Versuch;
// ProductDetailView hat die Mengen-Blaetter aus genau diesem Grund
// laengst auf die Form-Ebene gelegt. Hier stand es nur nie nach.
}
private func untertitel(_ it: Item) -> String {
// 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 {
items = (try? await APIClient.shared.items(productId: product.id)) ?? []
}
private func refresh() async { await reload(); await onChanged() }
}
// MARK: - Detail / Bearbeiten (auch Ziel eines Scans)
/// Lagerort per QR (…/l/<Code>) auswählen – selbstständige Scan-Ansicht, meldet
/// den getroffenen Lagerort zurück. So kann man einen Ort auch ohne den QR des
/// Einzelstücks setzen (z.B. bei Kleidung ohne eigenen Code).
struct LocationScannerView: View {
let locations: [StorageLocation]
var onPicked: (StorageLocation) -> Void
@Environment(\.dismiss) private var dismiss
@State private var paused = false
@State private var torchOn = false
@State private var torchLocked = false
@State private var error: String?
var body: some View {
NavigationStack {
ScannerView(onCode: { code in handle(code) },
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
.ignoresSafeArea(edges: .bottom)
.overlay(alignment: .bottom) {
VStack(spacing: 6) {
if let error {
Text(error)
.font(.callout).foregroundStyle(.white)
.padding(.horizontal, 12).padding(.vertical, 8)
.background(.red, in: Capsule())
}
Text("QR am Regal/Fach vor die Kamera halten")
.font(.callout).padding(10)
.background(.ultraThinMaterial, in: Capsule())
}
.padding(.bottom, 24)
}
.navigationTitle("Lagerort scannen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
ToolbarItem(placement: .topBarTrailing) {
TorchButton(isOn: $torchOn, locked: $torchLocked)
}
}
}
}
private func handle(_ code: String) {
guard let r = code.range(of: "/l/") else {
error = "Das ist kein Lagerort-QR."
return
}
let ziel = String(code[r.upperBound...])
.trimmingCharacters(in: CharacterSet(charactersIn: "/ "))
.uppercased()
if let treffer = locations.first(where: { $0.id == ziel }) {
onPicked(treffer)
dismiss()
} else {
error = "Dieser Lagerort ist hier nicht bekannt."
}
}
}
struct ItemEditView: View {
let item: Item
var onChanged: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@State private var locations: [StorageLocation] = []
@State private var shops: [ShopItem] = []
@State private var photo: Data?
@State private var locationId: String?
@State private var shopId: Int?
@State private var hasAcquired = false
@State private var acquired = Date()
@State private var hasWarranty = false
@State private var warranty = Date()
@State private var note = ""
@State private var priceText = ""
@State private var currency = "CHF"
@State private var documents: [ItemDocument] = []
@State private var suggWarranty: String?
@State private var suggPrice: Int?
@State private var suggPriceCandidates: [Int] = []
@State private var suggAcquired: String?
@State private var suggShopId: Int?
@State private var suggShopName: String?
@State private var busy = false
@State private var busyDoc = false
@State private var error: String?
@State private var showRemove = false
@State private var reason = "broken"
@State private var removeNote = ""
@State private var showFileImporter = false
@State private var pickerItem: PhotosPickerItem?
@State private var previewURL: URL?
@State private var locScanShown = false
@State private var nfcShown = false
@State private var zurueckFrage = false
// Zubehoer wird nachgeladen statt aus `item` genommen: Nach dem Zuordnen
// ist das uebergebene Stueck veraltet, die Ansicht aber noch offen.
@State private var teile: [ItemKurz] = []
@State private var gehoertZu: ItemKurz?
@State private var zubehoerWaehlen = false
@State private var mitVerleihen: Set<Int> = []
// 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
// 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 {
Section {
if let photo, let ui = UIImage(data: photo) {
Image(uiImage: ui)
.resizable().scaledToFit()
.frame(maxWidth: .infinity).frame(maxHeight: 220)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
if let name = item.productName {
LabeledContent("Artikel", value: name)
}
if let brand = item.productBrand, !brand.isEmpty {
LabeledContent("Marke", value: brand)
}
LabeledContent("UID", value: item.uid)
// Für Kleidung: ein eingenähtes NFC-Tag hält, was ein aufgeklebter
// QR nicht übersteht. Auf dem iPad ausgeblendet – dort gibt es kein NFC.
if NFCDienst.verfuegbar {
Button { nfcShown = true } label: {
Label("NFC-Tag beschreiben", systemImage: "wave.3.right")
}
}
}
// 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
// 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 { zurueckFrage = true } 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)
}
}
}
// Zubehoer: das Netzteil, das zum Laptop gehoert. Ein Teil haengt an
// genau einem Geraet, ein Geraet traegt beliebig viele Teile.
Section("Zubehör") {
if let g = gehoertZu {
LabeledContent("Gehört zu") {
VStack(alignment: .trailing, spacing: 1) {
Text(g.productName ?? "Einzelstück")
Text(g.uid).font(.caption.monospaced()).foregroundStyle(.secondary)
}
}
}
ForEach(teile) { t in
HStack {
VStack(alignment: .leading, spacing: 1) {
Text(t.productName ?? "Einzelstück")
Text(t.uid).font(.caption.monospaced()).foregroundStyle(.secondary)
}
Spacer()
if t.verliehen {
Text("verliehen").font(.caption).foregroundStyle(.orange)
}
}
.swipeActions {
Button("Lösen", role: .destructive) { Task { await loese(t) } }
}
}
if teile.isEmpty && gehoertZu == nil {
Text("Kein Zubehör.").foregroundStyle(.secondary)
}
Button { zubehoerWaehlen = true } label: {
Label("Zubehör hinzufügen", systemImage: "plus")
}
}
Section {
Picker("Lagerort", selection: $locationId) {
Text("– ohne –").tag(String?.none)
ForEach(baum(locations)) { e in Text(e.label).tag(String?.some(e.item.id)) }
}
// Lagerort per QR setzen – ohne den (evtl. nicht vorhandenen) QR
// des Einzelstücks. Praktisch z.B. bei Kleidung.
Button { locScanShown = true } label: {
Label("Lagerort scannen", systemImage: "qrcode.viewfinder")
}
Picker("Gekauft bei", selection: $shopId) {
Text("– unbekannt –").tag(Int?.none)
ForEach(shops) { s in Text(s.name).tag(Int?.some(s.id)) }
}
Toggle("Kaufdatum", isOn: $hasAcquired)
if hasAcquired { DateRow(label: "Gekauft am", date: $acquired) }
Toggle("Garantie", isOn: $hasWarranty)
if hasWarranty { DateRow(label: "Garantie bis", date: $warranty) }
LabeledField(label: "Notiz", text: $note)
}
Section("Kaufpreis") {
HStack {
TextField("0.00", text: $priceText).keyboardType(.decimalPad)
Picker("", selection: $currency) {
Text("CHF").tag("CHF")
Text("EUR").tag("EUR")
}
.pickerStyle(.segmented).frame(width: 130)
}
}
Section("Belege (Rechnung/Garantieschein)") {
if hatVorschlag {
VStack(alignment: .leading, spacing: 6) {
Text("Im Beleg erkannt:").font(.caption).foregroundStyle(.secondary)
if let a = suggAcquired { Text("Gekauft am \(a)") }
if let w = suggWarranty { Text("Garantie bis \(w)") }
if suggPriceCandidates.count > 1 {
// Mehrere Beträge gefunden – Auswahl anbieten.
Picker("Kaufpreis", selection: Binding(
get: { suggPrice ?? suggPriceCandidates.first ?? 0 },
set: { suggPrice = $0 }
)) {
ForEach(suggPriceCandidates, id: \.self) { c in
Text("\(ItemEditView.formatCents(c)) \(currency)").tag(c)
}
}
.pickerStyle(.menu)
} else if let p = suggPrice {
Text("Kaufpreis \(ItemEditView.formatCents(p)) \(currency)")
}
if let sid = suggShopId {
Text("Shop: \(shops.first(where: { $0.id == sid })?.name ?? "bekannt")")
} else if let name = suggShopName {
Text("Shop anlegen: \(name)")
}
HStack {
Button("Übernehmen") { Task { await applySuggestion() } }
Spacer()
Button("Verwerfen") { verwerfeVorschlag() }
.foregroundStyle(.secondary)
}
.font(.callout)
}
}
ForEach(documents) { d in
Button { Task { await openDocument(d) } } label: {
Label(d.filename, systemImage: "doc.text")
}
.swipeActions(edge: .trailing) {
Button("Löschen", role: .destructive) { Task { await deleteDoc(d) } }
}
}
if documents.isEmpty {
Text("Noch keine Belege.").foregroundStyle(.secondary)
}
PhotosPicker(selection: $pickerItem, matching: .images) {
Label("Bild hochladen", systemImage: "photo")
}
Button { showFileImporter = true } label: {
Label("PDF hochladen", systemImage: "doc.badge.plus")
}
if busyDoc { ProgressView() }
}
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
Section {
Button(busy ? "Speichern…" : "Speichern") { Task { await save() } }.disabled(busy)
Button("Entfernen (mit Grund)", role: .destructive) { showRemove = true }
}
}
.navigationTitle("Einzelstück")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } } }
.task { await load() }
.fullScreenCover(isPresented: $locScanShown) {
LocationScannerView(locations: locations) { loc in locationId = loc.id }
.ignoresSafeArea()
}
.sheet(isPresented: $nfcShown) {
NavigationStack { NFCSchreibenView(item: item) }
}
.confirmationDialog(
"„\(item.uid)“ zurücknehmen?",
isPresented: $zurueckFrage, titleVisibility: .visible
) {
Button("Zurücknehmen") { Task { await nimmZurueck() } }
Button("Abbrechen", role: .cancel) { }
} message: {
let wer = loan.map { "Von \($0.borrower)." } ?? ""
let mit = mitZurueck.isEmpty ? "" :
" Das mitverliehene Zubehör kommt mit zurück: "
+ mitZurueck.map(\.uid).joined(separator: ", ") + "."
Text(wer + mit)
}
.sheet(isPresented: $zubehoerWaehlen) {
// Dieselbe durchsuchbare Liste wie beim Zuordnen eines Lagerorts.
ItemPickerSheet { gewaehlt in
Task { await haengeAn(gewaehlt) }
}
}
.sheet(isPresented: $showRemove) {
NavigationStack { removeSheet }
}
.sheet(isPresented: $showLend) {
NavigationStack { lendSheet }
}
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.pdf]) { result in
if case .success(let url) = result {
Task {
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
if let data = try? Data(contentsOf: url) {
await upload(data: data, filename: url.lastPathComponent,
contentType: "application/pdf")
}
}
} else if case .failure(let err) = result {
error = err.localizedDescription
}
}
.onChange(of: pickerItem) { neu in
guard let neu else { return }
Task {
if let data = try? await neu.loadTransferable(type: Data.self) {
await upload(data: data, filename: "foto.jpg", contentType: "image/jpeg")
}
pickerItem = nil
}
}
.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.")
}
// Zubehoer geht nur mit, wenn es angehakt ist. Schon anderweitig
// verliehene Teile stehen nicht zur Wahl - die sind gar nicht da.
if !teile.filter({ !$0.verliehen }).isEmpty {
Section("Zubehör mitgeben?") {
ForEach(teile.filter { !$0.verliehen }) { t in
Button {
if mitVerleihen.contains(t.id) { mitVerleihen.remove(t.id) }
else { mitVerleihen.insert(t.id) }
} label: {
HStack {
Image(systemName: mitVerleihen.contains(t.id)
? "checkmark.circle.fill" : "circle")
.foregroundStyle(Color.marke)
VStack(alignment: .leading, spacing: 1) {
Text(t.productName ?? "Einzelstück").foregroundStyle(.primary)
Text(t.uid).font(.caption.monospaced())
.foregroundStyle(.secondary)
}
}
}
}
}
}
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) {
ForEach(RemovalReasons.all, id: \.value) { Text($0.label).tag($0.value) }
}
LabeledField(label: "Notiz", text: $removeNote)
Section {
Button("Entfernen", role: .destructive) { Task { await remove() } }
}
}
.navigationTitle("Entfernen")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { showRemove = false } } }
}
// MARK: - Verleih
private func starteVerleih() {
borrower = ""
loanReason = ""
hasDue = false
due = Date()
error = nil
// Vorausgewaehlt ist, was im Haus ist: Wer ein Geraet weitergibt, gibt
// das Netzteil meist mit. Abwaehlen geht mit einem Tipp.
mitVerleihen = Set(teile.filter { !$0.verliehen }.map(\.id))
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,
mitZubehoer: Array(mitVerleihen)))
showLend = false
await ladeVerleihe()
await ladeZubehoer()
onChanged?()
} catch { self.error = error.localizedDescription }
}
/// Mitverliehenes Zubehoer, das zusammen mit dem Geraet zurueckkommt.
private var mitZurueck: [ItemKurz] { teile.filter { $0.verliehen } }
private func nimmZurueck() async {
busyLoan = true; defer { busyLoan = false }; error = nil
do {
_ = try await APIClient.shared.returnItem(
id: item.id,
LoanReturnRequest(mitZubehoer: mitZurueck.map(\.id))
)
loan = nil
await ladeVerleihe()
await ladeZubehoer()
onChanged?()
} 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 {
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()) ?? []
photo = try? await APIClient.shared.productImage(id: item.productId)
locationId = item.locationId
shopId = item.shopId
note = item.note ?? ""
documents = item.documents
currency = item.currency ?? "CHF"
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
teile = item.teile
gehoertZu = item.gehoertZu
uebernimm(item.verwendung)
await ladeVerleihe()
await ladeVerwendungen()
}
private func haengeAn(_ gewaehlt: Item) async {
error = nil
do {
_ = try await APIClient.shared.zubehoerZuordnen(
itemId: item.id, childItemId: gewaehlt.id
)
await ladeZubehoer()
onChanged?()
} catch { self.error = error.localizedDescription }
}
private func loese(_ teil: ItemKurz) async {
error = nil
do {
try await APIClient.shared.zubehoerLoesen(itemId: item.id, childItemId: teil.id)
await ladeZubehoer()
onChanged?()
} catch { self.error = error.localizedDescription }
}
private func ladeZubehoer() async {
guard let frisch = try? await APIClient.shared.item(id: item.id) else { return }
teile = frisch.teile
gehoertZu = frisch.gehoertZu
loan = frisch.loan
}
private func save() async {
busy = true; defer { busy = false }; error = nil
let cents = ItemEditView.parseCents(priceText)
do {
_ = try await APIClient.shared.updateItem(id: item.id, ItemUpdateRequest(
locationId: locationId, shopId: shopId,
acquiredOn: hasAcquired ? dateToString(acquired) : nil,
warrantyUntil: hasWarranty ? dateToString(warranty) : nil,
note: note.isEmpty ? nil : note,
priceCents: cents,
currency: cents == nil ? nil : currency))
onChanged?()
dismiss()
} catch { self.error = error.localizedDescription }
}
// MARK: - Belege
private func upload(data: Data, filename: String, contentType: String) async {
busyDoc = true
defer { busyDoc = false }
do {
let res = try await APIClient.shared.uploadItemDocument(
itemId: item.id, data: data, filename: filename, contentType: contentType)
let s = res.suggestions
suggWarranty = s.suggestedWarrantyUntil
suggPrice = s.suggestedPriceCents
suggPriceCandidates = s.suggestedPriceCandidates
suggAcquired = s.suggestedAcquiredOn
suggShopId = s.suggestedShopId
suggShopName = s.suggestedShopName
await reloadDocuments()
} catch { self.error = error.localizedDescription }
}
private func reloadDocuments() async {
if let fresh = try? await APIClient.shared.itemByUid(uid: item.uid) {
documents = fresh.documents
}
}
private func openDocument(_ d: ItemDocument) async {
do {
let data = try await APIClient.shared.itemDocumentData(itemId: item.id, docId: d.id)
let name = d.filename.isEmpty ? "beleg" : d.filename
let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
try data.write(to: url)
previewURL = url
} catch { self.error = error.localizedDescription }
}
private func deleteDoc(_ d: ItemDocument) async {
do {
try await APIClient.shared.deleteItemDocument(itemId: item.id, docId: d.id)
await reloadDocuments()
} catch { self.error = error.localizedDescription }
}
private var hatVorschlag: Bool {
suggWarranty != nil || suggPrice != nil || suggAcquired != nil
|| suggShopId != nil || suggShopName != nil
}
private func applySuggestion() async {
if let a = suggAcquired, let d = stringToDate(a) { acquired = d; hasAcquired = true }
if let w = suggWarranty, let d = stringToDate(w) { warranty = d; hasWarranty = true }
if let p = suggPrice { priceText = ItemEditView.formatCents(p) }
if let sid = suggShopId {
shopId = sid
} else if let name = suggShopName,
let shop = try? await APIClient.shared.createShop(
NewShopRequest(name: name, website: nil)) {
if !shops.contains(where: { $0.id == shop.id }) { shops.append(shop) }
shopId = shop.id
}
verwerfeVorschlag()
}
private func verwerfeVorschlag() {
suggWarranty = nil
suggPrice = nil
suggPriceCandidates = []
suggAcquired = nil
suggShopId = nil
suggShopName = nil
}
/// Eingabe in Hauptwährungseinheit → Rappen/Cent.
static func parseCents(_ s: String) -> Int? {
let t = s.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: ",", with: ".")
guard !t.isEmpty, let v = Double(t) else { return nil }
return Int((v * 100).rounded())
}
static func formatCents(_ c: Int) -> String {
String(format: "%.2f", Double(c) / 100)
}
private func remove() async {
do {
try await APIClient.shared.removeItem(id: item.id, ItemRemoveRequest(
reason: reason, note: removeNote.isEmpty ? nil : removeNote))
showRemove = false
onChanged?()
dismiss()
} catch { self.error = error.localizedDescription; showRemove = false }
}
}
// MARK: - Anlegen
extension View {
/// Nach dem Anlegen eines Artikels gleich dessen Einzelstücke erfassen.
///
/// „Artikel anlegen" endete bisher im Nichts: `ProductFormView` liefert den
/// neuen Artikel, aber die Aufrufer warfen ihn weg – und „Einzelstücke
/// anlegen" steckt ausschliesslich in der Artikelansicht, die man von dort
/// aus gar nicht erreicht.
///
/// Die Bindung wird erst im `onDismiss` des Formulars gesetzt: Beides im
/// selben Zug zu setzen verschluckt SwiftUI, weil das zweite Sheet dann
/// erscheinen soll, waehrend das erste noch verschwindet.
/// Je nach Verwaltungsart geht es zu den Einzelstuecken oder zur Menge je
/// Lagerort. Beides haengt an EINEM sheet-Modifikator, nicht an zweien:
/// Zwei Praesentationen am selben View vertragen sich in SwiftUI nicht
/// zuverlaessig - dann schliesst sich das Blatt beim ersten Mal sofort
/// wieder und erst der zweite Anlauf haelt.
func nachAnlegen(_ artikel: Binding<Product?>) -> some View {
sheet(item: artikel) { p in
NavigationStack {
if fuehrtEinzelstuecke(p) {
ItemAddSheet(productId: p.id,
onDone: {},
onFinish: { artikel.wrappedValue = nil })
} else {
MengeNachAnlegenSheet(product: p) { artikel.wrappedValue = nil }
}
}
}
}
}
/// Hat dieser Artikel ueberhaupt Einzelstuecke? Nur Gegenstaende, die je Stueck
/// eine eigene UID fuehren – Lebensmittel und Mengen-Gegenstaende nicht.
func fuehrtEinzelstuecke(_ p: Product?) -> Bool {
guard let p else { return false }
return p.isObject && p.isIndividual
}
/// Gegenstand, dessen Bestand nur als Menge je Lagerort gezaehlt wird – also
/// weder Einzelstuecke noch Verbrauchsgegenstand (der laeuft als Charge).
func fuehrtMengeJeLagerort(_ p: Product?) -> Bool {
guard let p else { return false }
return p.isObject && !p.isIndividual && !p.isBulk
}
/// Laedt die Lagerorte nach und zeigt dann das vorhandene Mengen-Blatt.
///
/// `ObjectAddSheet` nimmt die Lagerorte als Konstante und holt sie – anders als
/// `ItemAddSheet` – nicht selbst. Direkt nach dem Anlegen hat aber niemand eine
/// Liste zur Hand, deshalb diese duenne Schale.
private struct MengeNachAnlegenSheet: View {
let product: Product
var onFertig: () -> Void
@State private var locations: [StorageLocation] = []
var body: some View {
ObjectAddSheet(
product: product,
locations: locations,
einheit: product.unitName.isEmpty ? "Stück" : product.unitName,
perform: { onFertig() }
)
.task { locations = (try? await APIClient.shared.locations()) ?? [] }
}
}
struct ItemAddSheet: View {
let productId: Int
var onDone: () async -> Void
/// Wenn gesetzt: „Anlegen & fertig" schließt bis zur Liste (nicht nur das Sheet).
var onFinish: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@State private var count = 1
@State private var locations: [StorageLocation] = []
@State private var shops: [ShopItem] = []
@State private var locationId: String?
@State private var shopId: Int?
@State private var hasAcquired = false
@State private var acquired = Date()
@State private var hasWarranty = false
@State private var warranty = Date()
@State private var note = ""
@State private var priceText = ""
@State private var currency = "CHF"
@State private var docData: Data?
@State private var docName = ""
@State private var docType = ""
@State private var newShopName: String?
@State private var addInfo: String?
@State private var showFileImporter = false
@State private var pickerItem: PhotosPickerItem?
@State private var busy = false
@State private var error: String?
@State private var locScanShown = false
/// Frisch angelegtes Stueck, dessen Tag gleich beschrieben werden soll.
@State private var nfcItem: Item?
/// Loest den Schub zum Tag-Schritt aus (navigationDestination, iOS 16).
@State private var nfcPush = false
/// Ob nach dem Tag-Schritt bis zur Liste geschlossen wird („Anlegen & fertig").
@State private var nfcFinish = false
var body: some View {
Form {
Section {
Stepper("Anzahl: \(count)", value: $count, in: 1...200)
Picker("Lagerort", selection: $locationId) {
Text("– ohne –").tag(String?.none)
ForEach(baum(locations)) { e in Text(e.label).tag(String?.some(e.item.id)) }
}
Button { locScanShown = true } label: {
Label("Lagerort scannen", systemImage: "qrcode.viewfinder")
}
Picker("Gekauft bei", selection: $shopId) {
Text("– unbekannt –").tag(Int?.none)
ForEach(shops) { s in Text(s.name).tag(Int?.some(s.id)) }
}
Toggle("Kaufdatum", isOn: $hasAcquired)
if hasAcquired { DateRow(label: "Gekauft am", date: $acquired) }
Toggle("Garantie", isOn: $hasWarranty)
if hasWarranty { DateRow(label: "Garantie bis", date: $warranty) }
LabeledField(label: "Notiz", text: $note)
} footer: {
Text("Gemeinsame Startwerte – danach je Stück änderbar. Jedes Stück bekommt eine eigene UID + QR.")
}
Section("Kaufpreis") {
HStack {
TextField("0.00", text: $priceText).keyboardType(.decimalPad)
Picker("", selection: $currency) {
Text("CHF").tag("CHF")
Text("EUR").tag("EUR")
}
.pickerStyle(.segmented).frame(width: 130)
}
}
Section {
if docData != nil { Text(docName).font(.callout) }
PhotosPicker(selection: $pickerItem, matching: .images) {
Label("Bild wählen", systemImage: "photo")
}
Button { showFileImporter = true } label: {
Label("PDF wählen", systemImage: "doc.badge.plus")
}
if let addInfo { Text(addInfo).font(.caption).foregroundStyle(.secondary) }
if let newShopName {
Text("Neuer Shop „\(newShopName)“ wird beim Anlegen erstellt.")
.font(.caption).foregroundStyle(.secondary)
}
} header: {
Text("Beleg (Rechnung/Garantieschein)")
} footer: {
Text("Der Beleg wird an das erste angelegte Stück gehängt; aus einem PDF werden Kaufdatum, Garantie, Preis und Shop vorgeschlagen.")
}
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
Section {
Button(busy ? "Anlegen…" : "Anlegen") { Task { await create(finish: false) } }.disabled(busy)
if onFinish != nil {
Button(busy ? "Anlegen…" : "Anlegen & fertig") { Task { await create(finish: true) } }
.disabled(busy)
}
// Der Knopf steht hier und nicht weiter oben, weil ein Tag erst
// beschrieben werden kann, wenn das Stueck existiert: Die UID
// vergibt der Server beim Anlegen. Deshalb eine Taste, die
// beides tut - sichtbar an der Stelle, wo man sie sucht.
if count == 1, NFCDienst.verfuegbar {
Button { Task { await create(finish: false, tagDanach: true) } } label: {
Label(busy ? "Anlegen…" : "Anlegen & NFC-Tag beschreiben",
systemImage: "wave.3.right")
}
.disabled(busy)
}
} footer: {
if NFCDienst.verfuegbar && count > 1 {
Text("Ein NFC-Tag gehört zu genau einem Stück. Bei Anzahl 1 lässt "
+ "er sich hier gleich mit beschreiben.")
}
}
}
.navigationTitle("Einzelstücke anlegen")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
.fullScreenCover(isPresented: $locScanShown) {
LocationScannerView(locations: locations) { loc in locationId = loc.id }
.ignoresSafeArea()
}
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.pdf]) { result in
if case .success(let url) = result {
Task {
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
if let data = try? Data(contentsOf: url) {
await analyze(data, name: url.lastPathComponent, type: "application/pdf")
}
}
}
}
.onChange(of: pickerItem) { neu in
guard let neu else { return }
Task {
if let data = try? await neu.loadTransferable(type: Data.self) {
await analyze(data, name: "foto.jpg", type: "image/jpeg")
}
pickerItem = nil
}
}
.task {
locations = (try? await APIClient.shared.locations()) ?? []
shops = (try? await APIClient.shared.shops()) ?? []
}
// GESCHOBEN und nicht als zweites Blatt aufgelegt. Ein Blatt aus einem
// Blatt heraus ist hier gleich dreifach riskant: Dieses Formular traegt
// schon ein fullScreenCover (Lagerort scannen), und es wird selbst aus
// einer Section heraus gezeigt - wovor ProductDetailView:349 und
// ItemViews:775 jeweils aus eigener Erfahrung warnen. Ein Schub in den
// ohnehin vorhandenen NavigationStack umgeht alle drei Fallen.
.navigationDestination(isPresented: $nfcPush) {
if let stueck = nfcItem {
NFCSchreibenView(item: stueck, onFertig: {
// Jetzt erst auffrischen: vorher raeumte das Neuladen der
// Elternansicht dieses Blatt samt Tag-Schritt ab.
Task {
await onDone()
// Erst den Schub verlassen, dann das Blatt: Solange der
// Tag-Schritt oben liegt, greift dismiss() auf ihn und
// nicht auf das Blatt darunter.
nfcPush = false
if nfcFinish, let onFinish { onFinish() } else { dismiss() }
}
})
}
}
}
/// Beleg beim Anlegen analysieren und Felder vorbefüllen (nur bei PDF liefert
/// der Server Treffer).
private func analyze(_ data: Data, name: String, type: String) async {
docData = data
docName = name
docType = type
addInfo = nil
newShopName = nil
guard let s = try? await APIClient.shared.analyzeItemDocument(
data: data, filename: name, contentType: type) else { return }
var teile: [String] = []
if let a = s.suggestedAcquiredOn, let d = stringToDate(a) {
acquired = d; hasAcquired = true; teile.append("Kaufdatum")
}
if let w = s.suggestedWarrantyUntil, let d = stringToDate(w) {
warranty = d; hasWarranty = true; teile.append("Garantie")
}
if let p = s.suggestedPriceCents {
priceText = ItemEditView.formatCents(p); teile.append("Preis")
}
if let sid = s.suggestedShopId {
shopId = sid; teile.append("Shop")
} else if let sn = s.suggestedShopName {
newShopName = sn
}
addInfo = teile.isEmpty ? "Beleg erkannt – keine Automatik-Treffer."
: "Übernommen: \(teile.joined(separator: ", "))."
}
private func create(finish: Bool, tagDanach: Bool = false) async {
busy = true; defer { busy = false }; error = nil
do {
var sid = shopId
if sid == nil, let name = newShopName,
let shop = try? await APIClient.shared.createShop(
NewShopRequest(name: name, website: nil)) {
sid = shop.id
}
let cents = ItemEditView.parseCents(priceText)
let created = try await APIClient.shared.createItems(productId: productId, ItemCreateRequest(
count: count, locationId: locationId, shopId: sid,
acquiredOn: hasAcquired ? dateToString(acquired) : nil,
warrantyUntil: hasWarranty ? dateToString(warranty) : nil,
note: note.isEmpty ? nil : note,
priceCents: cents,
currency: cents == nil ? nil : currency))
if let data = docData, let first = created.first {
_ = try? await APIClient.shared.uploadItemDocument(
itemId: first.id, data: data,
filename: docName.isEmpty ? "beleg" : docName, contentType: docType)
}
// Weiter zum Tag-Schritt - und zwar BEVOR onDone() laeuft.
//
// onDone() laedt die Elternansicht neu, und dabei wird dieses Blatt
// abgeraeumt: Jeder Zustand, den man danach setzt, laeuft ins Leere.
// Genau daran ist der Schritt vorher unsichtbar gescheitert. Das
// Neuladen holt deshalb „Fertig" im Tag-Schritt nach.
if tagDanach, created.count == 1, NFCDienst.verfuegbar {
nfcFinish = finish
nfcItem = created[0]
nfcPush = true
return
}
await onDone()
if finish, let onFinish { onFinish() } else { dismiss() }
} catch { self.error = error.localizedDescription }
}
}
// 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
/// 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))"
}
}