Gewuenscht war ein Knopf, den man sieht. Gebaut war ein Schritt, der erst NACH dem Antippen von "Anlegen" kam - im Formular also nichts zu sehen. Und er kam nicht einmal dann: await onDone() laedt die Elternansicht neu, dabei wird das Anlege-Blatt abgeraeumt, und jeder Zustand, den man danach setzt, laeuft ins Leere. Deshalb war von dem Schritt nie etwas zu sehen. Jetzt steht "Anlegen & NFC-Tag beschreiben" neben "Anlegen", mit Wellensymbol und nur bei Anzahl 1 (ein Tag gehoert zu genau einem Stueck; bei mehr sagt ein Hinweis, warum er fehlt). Frueher kann der Knopf nicht stehen: Die UID vergibt der Server erst beim Anlegen, vorher gibt es nichts zu schreiben. Das Neuladen holt jetzt "Fertig" im Tag-Schritt nach, statt vorher zu laufen. Diesmal in der laufenden App geprueft, nicht nur uebersetzt: Backend lokal auf SQLite, Testartikel angelegt, Ablauf im Simulator durchgespielt. Der Knopf erscheint, legt das Stueck an und fuehrt zum Tag-Schritt mit der richtigen UID und vorrania://i/<UID> als Nutzlast. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1126 lines
47 KiB
Swift
1126 lines
47 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 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 }
|
||
}
|
||
|
||
/// 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 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: - 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))"
|
||
}
|
||
}
|