Files
Vorrania/ios/Sources/ItemViews.swift
T
ScarriffleandClaude Opus 5 ea7a42ac22 App: Tag beim Anlegen, Menge beim Anlegen, Loeschen und NFC beim Ein-/Auslagern
Vier Bruchstellen im taeglichen Gebrauch, alle an derselben Wurzel: Nach dem
Anlegen endete der Weg, und was danach kam, musste man sich selbst suchen.

NFC beim Anlegen: createItems liefert die angelegten Stuecke samt UID laengst
zurueck, ItemAddSheet warf sie nur weg. Bei genau EINEM Stueck geht es jetzt
direkt zum Tag-Blatt. Bei mehreren bleibt es wie bisher - die App koennte
nicht wissen, welches der fuenf Stuecke gerade am Tag haengt. Ohne NFC im
Geraet (iPad) ebenso unveraendert.

Menge beim Anlegen: Ein Gegenstand, der nur als Menge je Lagerort gezaehlt
wird, landete nach dem Anlegen im Nichts. Das Blatt dafuer gab es schon
(ObjectAddSheet), nur fuehrte kein Weg dorthin. Jetzt derselbe Uebergang wie
bei Einzelstuecken, an beiden Einstiegen. Es holt sich die Lagerorte selbst -
ObjectAddSheet nimmt sie als Konstante und laedt sie nicht nach.

Loeschen: Der Knopf sitzt unten in der Artikelansicht, nur fuer Admins - die
Route verlangt das, ohne Riegel gaebe es nur ein wortkarges 403. Lehnt der
Server ab (noch Einzelstuecke oder Bestand), bleibt die Ansicht stehen und
seine Begruendung steht unveraendert in der Fehlerzeile.

NFC beim Ein- und Auslagern: Ein Tag benennt immer genau ein Einzelstueck,
deshalb fuehrt er zur Stueck-Ansicht - beim Auslagern ist "Entfernen (mit
Grund)" genau die passende Aktion. Bewusst NICHT ueber resolve() abgekuerzt:
das faellt bei unbekannter UID auf lookup(barcode:) durch und boete an, einen
Artikel mit einer URL als Barcode anzulegen. Beim Nachschlagen gab es NFC
schon, nur unscheinbar unter der Kamerakarte - Knopf abgesetzt und in der
Bildunterschrift erwaehnt.

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

1091 lines
46 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 })
}
}
}
/// 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?
/// Ob nach dem Tag-Blatt 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)
}
}
}
.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()) ?? []
}
// Blatt auf Blatt: Das Anlege-Blatt bleibt stehen, bis der Tag
// geschrieben (oder uebersprungen) ist. Erst sein Verschwinden schliesst
// beide - das ist die Reihenfolge, die SwiftUI vertraegt.
.sheet(item: $nfcItem, onDismiss: {
if nfcFinish, let onFinish { onFinish() } else { dismiss() }
}) { stueck in
NavigationStack { NFCSchreibenView(item: stueck) }
}
}
/// Beleg beim Anlegen analysieren und Felder vorbefüllen (nur bei PDF liefert
/// der Server Treffer).
private func analyze(_ data: Data, name: String, type: String) async {
docData = data
docName = name
docType = type
addInfo = nil
newShopName = nil
guard let s = try? await APIClient.shared.analyzeItemDocument(
data: data, filename: name, contentType: type) else { return }
var teile: [String] = []
if let a = s.suggestedAcquiredOn, let d = stringToDate(a) {
acquired = d; hasAcquired = true; teile.append("Kaufdatum")
}
if let w = s.suggestedWarrantyUntil, let d = stringToDate(w) {
warranty = d; hasWarranty = true; teile.append("Garantie")
}
if let p = s.suggestedPriceCents {
priceText = ItemEditView.formatCents(p); teile.append("Preis")
}
if let sid = s.suggestedShopId {
shopId = sid; teile.append("Shop")
} else if let sn = s.suggestedShopName {
newShopName = sn
}
addInfo = teile.isEmpty ? "Beleg erkannt – keine Automatik-Treffer."
: "Übernommen: \(teile.joined(separator: ", "))."
}
private func create(finish: Bool) async {
busy = true; defer { busy = false }; error = nil
do {
var sid = shopId
if sid == nil, let name = newShopName,
let shop = try? await APIClient.shared.createShop(
NewShopRequest(name: name, website: nil)) {
sid = shop.id
}
let cents = ItemEditView.parseCents(priceText)
let created = try await APIClient.shared.createItems(productId: productId, ItemCreateRequest(
count: count, locationId: locationId, shopId: sid,
acquiredOn: hasAcquired ? dateToString(acquired) : nil,
warrantyUntil: hasWarranty ? dateToString(warranty) : nil,
note: note.isEmpty ? nil : note,
priceCents: cents,
currency: cents == nil ? nil : currency))
if let data = docData, let first = created.first {
_ = try? await APIClient.shared.uploadItemDocument(
itemId: first.id, data: data,
filename: docName.isEmpty ? "beleg" : docName, contentType: docType)
}
await onDone()
// Genau EIN Stueck: gleich den Tag beschreiben, statt das Stueck
// hinterher in der Liste wiederzusuchen. Bei mehreren koennte die
// App nicht wissen, welches davon gerade am Tag haengt - dort
// bleibt es wie bisher. Ohne NFC im Geraet (iPad) ebenso.
if created.count == 1, NFCDienst.verfuegbar {
nfcFinish = finish
nfcItem = created[0]
return // Schliessen uebernimmt das onDismiss des Tag-Blatts
}
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))"
}
}