Nach dem Anlegen eines einzelnen Stuecks sollte das Tag-Blatt aufgehen. Es kam nie. Ein zweites Blatt aus einem Blatt heraus ist hier gleich dreifach riskant: ItemAddSheet traegt schon ein fullScreenCover (Lagerort scannen) - zwei Praesentations-Modifikatoren am selben View vertragen sich in SwiftUI nicht zuverlaessig - und das Formular wird selbst aus einer Section heraus gezeigt. Vor beidem warnen ProductDetailView:349 und ItemViews:775 bereits aus eigener Erfahrung. Der Tag-Schritt wird deshalb in den ohnehin vorhandenen NavigationStack geschoben. Ein Schub kennt keine dieser Fallen und ist fuer einen Schritt in einem Ablauf ohnehin die passendere Form. NFCSchreibenView bekommt dafuer ein optionales onFertig. Ohne Angabe schliesst sie sich selbst wie bisher - der Aufruf aus der Stueck-Bearbeitung bleibt unveraendert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1101 lines
46 KiB
Swift
1101 lines
46 KiB
Swift
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)
|
||
}
|
||
|
||
/// 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
|
||
|
||
@State private var items: [Item] = []
|
||
@State private var showAdd = false
|
||
|
||
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 { showAdd = true } label: {
|
||
Label("Einzelstücke anlegen", systemImage: "plus")
|
||
}
|
||
}
|
||
.task(id: product.id) { await reload() }
|
||
.sheet(isPresented: $showAdd) {
|
||
NavigationStack {
|
||
ItemAddSheet(productId: product.id, onDone: { await refresh() },
|
||
onFinish: onFinish.map { finish in { showAdd = false; finish() } })
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
|
||
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")
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 BaumZeile(zeile: e).tag(String?.some(e.item.id)) }
|
||
}
|
||
.pickerStyle(.navigationLink)
|
||
// 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 }
|
||
}
|
||
|
||
/// 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
|
||
await ladeVerleihe()
|
||
}
|
||
|
||
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.
|
||
func einzelstueckeNachAnlegen(_ artikel: Binding<Product?>) -> some View {
|
||
sheet(item: artikel) { p in
|
||
NavigationStack {
|
||
ItemAddSheet(productId: p.id,
|
||
onDone: {},
|
||
onFinish: { artikel.wrappedValue = nil })
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Dasselbe fuer Gegenstaende, die nur als Menge je Lagerort gefuehrt
|
||
/// werden: direkt zu „davon habe ich zwoelf Stueck", statt den gerade
|
||
/// angelegten Artikel hinterher wiederzusuchen.
|
||
///
|
||
/// Gleiche Reihenfolge-Regel wie oben – die Bindung erst im `onDismiss`
|
||
/// des Formulars setzen.
|
||
func mengeNachAnlegen(_ artikel: Binding<Product?>) -> some View {
|
||
sheet(item: artikel) { p in
|
||
NavigationStack { 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 BaumZeile(zeile: e).tag(String?.some(e.item.id)) }
|
||
}
|
||
.pickerStyle(.navigationLink)
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
.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: {
|
||
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) 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)
|
||
}
|
||
await onDone()
|
||
// Genau EIN Stueck: gleich den Tag beschreiben, statt das Stueck
|
||
// hinterher in der Liste wiederzusuchen. Bei mehreren koennte die
|
||
// App nicht wissen, welches davon gerade am Tag haengt - dort
|
||
// bleibt es wie bisher. Ohne NFC im Geraet (iPad) ebenso.
|
||
if created.count == 1, NFCDienst.verfuegbar {
|
||
nfcFinish = finish
|
||
nfcItem = created[0]
|
||
nfcPush = true
|
||
return // Schliessen uebernimmt „Fertig" im Tag-Schritt
|
||
}
|
||
if finish, let onFinish { onFinish() } else { dismiss() }
|
||
} 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))"
|
||
}
|
||
}
|