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 /// 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/) 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 = [] // 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/) 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. /// 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) -> 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: - 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))" } }