Files
Vorrania/ios/Sources/ItemViews.swift
T
ScarriffleandClaude Opus 5 501d80a9b8 Lagerort- und Kategorieauswahl zeigen den Baum statt einer Mischung
Die Auswahl war nicht flach, weil die Beschriftung gefehlt haette - der volle
Pfad stand laengst drin. Sie kam vom Server nach dem BLATTnamen sortiert, und
so standen "Garage -> Fach A" und "Keller -> Fach A" irgendwo zwischen allem
anderen. Geschwister und Kinder fanden nie zusammen.

Neu ist baum() in Baum.swift: bringt eine flache Eltern-Kind-Liste in
Baumreihenfolge und rueckt je Ebene ein. Waisen gelten als oberste Ebene,
damit nichts lautlos aus der Auswahl faellt.

Derselbe Lauf lag bisher dreimal im Projekt (CategoryPicker, TreeMasterView,
LocationEditor). Alle drei zeigen jetzt auf den einen - unterm Strich
verschwinden mehr Zeilen als dazukommen. Zwei der Kopien waren auch nicht
gleichwertig: LocationEditor.pfad hatte den Zyklusschutz von path(in:) nicht
und waere bei einer ringfoermigen Elternkette endlos gelaufen.

Umgestellt sind neun Auswahlfelder. Die Lagerortzeile beim Kassenzettel zeigte
nur den nackten Namen und bekommt den vollen Pfad - dort ist kein Platz zum
Einruecken.

MARKETING_VERSION stand auf 1.2 in der project.pbxproj, aber auf 1.1 in der
project.yml, aus der sie erzeugt wird. Die Quelle nachgezogen, sonst haette
das naechste "xcodegen generate" die Anhebung zurueckgedreht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 20:11:25 +02:00

1029 lines
43 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import PhotosUI
import QuickLook
import UniformTypeIdentifiers
private func dateToString(_ d: Date) -> String {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.string(from: d)
}
private func stringToDate(_ s: String) -> Date? {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.date(from: s)
}
/// "2026-09-20" -> "20.09.2026". Faellt auf den Rohwert zurueck, damit nie
/// eine leere Zeile steht.
private func anzeigeDatum(_ iso: String?) -> String {
guard let iso else { return "–" }
guard let d = stringToDate(iso) else { return iso }
let f = DateFormatter()
f.locale = Locale(identifier: "de_DE")
f.dateFormat = "dd.MM.yyyy"
return f.string(from: d)
}
/// 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 })
}
}
}
}
/// 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
}
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
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)
}
}
}
.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()) ?? []
}
}
/// 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()
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))"
}
}