From ea7a42ac22a77f83b4d5cbf96764f4decc3c6d61 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 15 Oct 2026 20:55:37 +0200 Subject: [PATCH] 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 --- ios/Sources/APIClient.swift | 6 +++ ios/Sources/CheckInView.swift | 16 ++++++++ ios/Sources/CheckOutView.swift | 24 +++++++++++ ios/Sources/ItemViews.swift | 62 +++++++++++++++++++++++++++++ ios/Sources/ListViews.swift | 7 +++- ios/Sources/LookupScanView.swift | 5 ++- ios/Sources/ProductDetailView.swift | 37 +++++++++++++++++ ios/Sources/RootView.swift | 3 ++ 8 files changed, 157 insertions(+), 3 deletions(-) diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index 70aec54..961f97f 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -216,6 +216,12 @@ actor APIClient { return try await send(request, as: Product.self) } + /// Artikel löschen. Der Server lehnt mit 409 ab, solange Einzelstücke oder + /// Chargen dranhängen – die Begründung steht dann in der Fehlermeldung. + func deleteProduct(id: Int) async throws { + try await sendNoContent(try makeRequest("/products/\(id)", method: "DELETE")) + } + /// Mindestbestände je Lagerort ersetzen (Menge in Artikeleinheiten). func setProductLocationMinStock(id: Int, _ list: [LocationMinStockIn]) async throws -> Product { var request = try makeRequest("/products/\(id)/location-min-stock", method: "PUT") diff --git a/ios/Sources/CheckInView.swift b/ios/Sources/CheckInView.swift index d065c62..c2fa670 100644 --- a/ios/Sources/CheckInView.swift +++ b/ios/Sources/CheckInView.swift @@ -56,6 +56,22 @@ struct CheckInView: View { Label("EAN von Hand eingeben", systemImage: "keyboard") } + // Eingenaehte Tags an Kleidung lassen sich nicht scannen. Ein + // Tag benennt immer genau ein Einzelstueck, deshalb fuehrt er + // dorthin und nicht in die Artikelsuche. + NFCLesenButton(titel: "NFC-Tag lesen") { uid in + Task { + error = nil; status = nil + if let found = try? await APIClient.shared.itemByUid(uid: uid) { + scannedItem = found + } else { + error = "Kein Einzelstück mit der UID \(uid)." + } + } + } onFehler: { meldung in + error = meldung + } + NavigationLink { ProductFormView(prefillBarcode: nil, groupId: nil) { created in product = created diff --git a/ios/Sources/CheckOutView.swift b/ios/Sources/CheckOutView.swift index 50253d4..b6c5978 100644 --- a/ios/Sources/CheckOutView.swift +++ b/ios/Sources/CheckOutView.swift @@ -13,6 +13,8 @@ struct CheckOutView: View { @State private var manualCodeShown = false @State private var manualCode = "" @State private var searchShown = false + /// Per NFC gelesenes Einzelstueck – Auslagern laeuft dort ueber „Entfernen". + @State private var scannedItem: Item? var body: some View { // Kamera als Feld oben, siehe CheckInView. @@ -48,6 +50,25 @@ struct CheckOutView: View { .frame(maxWidth: .infinity) } .buttonStyle(.bordered) + + // Ein Tag benennt immer genau ein Einzelstueck. Auslagern + // heisst dort „entfernen mit Grund", und das steht in der + // Stueck-Ansicht – deshalb fuehrt der Tag dorthin. + NFCLesenButton(titel: "NFC-Tag lesen") { uid in + Task { + error = nil; status = nil + paused = true + if let found = try? await APIClient.shared.itemByUid(uid: uid) { + scannedItem = found + } else { + error = "Kein Einzelstück mit der UID \(uid)." + paused = false + } + } + } onFehler: { meldung in + error = meldung + } + .buttonStyle(.bordered) } .padding(.horizontal) } @@ -70,6 +91,9 @@ struct CheckOutView: View { } Button("Abbrechen", role: .cancel) { paused = false } } + .sheet(item: $scannedItem, onDismiss: { paused = false }) { it in + NavigationStack { ItemEditView(item: it) } + } .sheet(isPresented: $searchShown) { NavigationStack { ProductSearchView { picked in diff --git a/ios/Sources/ItemViews.swift b/ios/Sources/ItemViews.swift index 6bd585f..05f0cd0 100644 --- a/ios/Sources/ItemViews.swift +++ b/ios/Sources/ItemViews.swift @@ -785,6 +785,18 @@ extension View { } } } + + /// 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) -> some View { + sheet(item: artikel) { p in + NavigationStack { MengeNachAnlegenSheet(product: p) { artikel.wrappedValue = nil } } + } + } } /// Hat dieser Artikel ueberhaupt Einzelstuecke? Nur Gegenstaende, die je Stueck @@ -794,6 +806,35 @@ func fuehrtEinzelstuecke(_ p: Product?) -> Bool { 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 @@ -824,6 +865,10 @@ struct ItemAddSheet: View { @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 { @@ -919,6 +964,14 @@ struct ItemAddSheet: View { 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 @@ -973,6 +1026,15 @@ struct ItemAddSheet: View { 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 } } diff --git a/ios/Sources/ListViews.swift b/ios/Sources/ListViews.swift index 5668a8c..03cf229 100644 --- a/ios/Sources/ListViews.swift +++ b/ios/Sources/ListViews.swift @@ -410,6 +410,7 @@ struct ProductListView: View { // Der gerade angelegte Artikel, bis sich das Formular geschlossen hat. @State private var angelegt: Product? @State private var einzelstueckeFuer: Product? + @State private var mengeFuer: Product? enum SortMode: String, CaseIterable, Identifiable { case nameAsc = "Name A–Z" @@ -538,13 +539,14 @@ struct ProductListView: View { } .sheet(isPresented: $showNew, onDismiss: { if fuehrtEinzelstuecke(angelegt) { einzelstueckeFuer = angelegt } + else if fuehrtMengeJeLagerort(angelegt) { mengeFuer = angelegt } angelegt = nil }) { NavigationStack { // Ohne Barcode: nur Name + Kategorie nötig. Typ aus der Liste // vorbelegen. Nach dem Anlegen schließt sich das Formular, die - // Liste wird aufgefrischt – und bei Gegenständen mit eigener UID - // je Stück geht es gleich weiter zum Anlegen der Einzelstücke. + // Liste wird aufgefrischt – und bei Gegenständen geht es gleich + // weiter: zu den Einzelstücken, oder zur Menge je Lagerort. ProductFormView(prefillBarcode: nil, groupId: nil, initialType: fixedType) { created in angelegt = created showNew = false @@ -558,6 +560,7 @@ struct ProductListView: View { } } .einzelstueckeNachAnlegen($einzelstueckeFuer) + .mengeNachAnlegen($mengeFuer) .task { categories = (try? await APIClient.shared.categories()) ?? [] } // Bei jedem Erscheinen neu laden – auch beim Zurückkommen aus der // Detailansicht, damit ein dort geänderter Bestand (z.B. neu angelegte diff --git a/ios/Sources/LookupScanView.swift b/ios/Sources/LookupScanView.swift index d3906eb..1cae307 100644 --- a/ios/Sources/LookupScanView.swift +++ b/ios/Sources/LookupScanView.swift @@ -23,7 +23,8 @@ struct LookupScanView: View { .clipShape(RoundedRectangle(cornerRadius: 16)) .padding(.horizontal) - Text("Barcode, Einzelstück- oder Lagerort-QR scannen – nur ansehen") + Text("Barcode, Einzelstück- oder Lagerort-QR scannen – oder unten " + + "einen NFC-Tag lesen. Nur ansehen.") .font(.caption).foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -41,6 +42,8 @@ struct LookupScanView: View { } onFehler: { meldung in error = meldung } + // Unauffaellig unter der Kamerakarte uebersieht man ihn sonst. + .buttonStyle(.bordered) if let error { Text(error).font(.callout).foregroundStyle(.red) diff --git a/ios/Sources/ProductDetailView.swift b/ios/Sources/ProductDetailView.swift index 08847ff..aacc8f2 100644 --- a/ios/Sources/ProductDetailView.swift +++ b/ios/Sources/ProductDetailView.swift @@ -5,10 +5,12 @@ struct ProductDetailView: View { let product: Product @EnvironmentObject private var display: DisplaySettings + @EnvironmentObject private var session: Session @Environment(\.dismiss) private var dismiss @State private var current: Product @State private var showLeaveConfirm = false + @State private var pendingDelete = false @State private var lots: [Lot] = [] @State private var busy = false @State private var error: String? @@ -321,6 +323,14 @@ struct ProductDetailView: View { Label("Verlauf dieses Artikels", systemImage: "clock.arrow.circlepath") } } + + // Loeschen verlangt Adminrechte (DELETE /products/{id}). Ohne den + // Riegel bekaeme ein normaler Benutzer nur ein wortkarges 403. + if session.isAdmin { + Section { + Button("Artikel löschen", role: .destructive) { pendingDelete = true } + } + } } .navigationTitle(current.name) .navigationBarTitleDisplayMode(.inline) @@ -339,6 +349,14 @@ struct ProductDetailView: View { Text("Änderungen an Name/Marke/Kategorie sind noch nicht gespeichert. " + "Foto und Lagerort-Bedarf werden sofort gespeichert.") } + .confirmationDialog("„\(current.name)“ löschen?", isPresented: $pendingDelete, + titleVisibility: .visible) { + Button("Löschen", role: .destructive) { Task { await loeschen() } } + Button("Abbrechen", role: .cancel) {} + } message: { + Text("Barcodes, Bild und der Bewegungsverlauf gehen dabei verloren. " + + "Hat der Artikel noch Einzelstücke oder Bestand, lehnt der Server ab.") + } .sheet(item: $editLot) { lot in NavigationStack { LotEditView(lot: lot, product: current, locations: locations) { @@ -606,6 +624,25 @@ struct ProductDetailView: View { } await reload() } + + /// Artikel loeschen und zurueck zur Liste. + /// + /// Bewusst `dismiss()` und nicht `attemptLeave()`: Letzteres fragte nach + /// ungespeicherten Aenderungen an einem Artikel, den es nicht mehr gibt. + /// Lehnt der Server ab (409, noch Einzelstuecke oder Bestand), bleibt die + /// Ansicht stehen und seine Begruendung landet unveraendert in der + /// Fehlerzeile – sie nennt genau, was noch dranhaengt. + private func loeschen() async { + error = nil + busy = true + defer { busy = false } + do { + try await APIClient.shared.deleteProduct(id: current.id) + dismiss() + } catch { + self.error = error.localizedDescription + } + } } /// Lädt ein Produkt anhand seiner ID nach und zeigt dann die Detailansicht – diff --git a/ios/Sources/RootView.swift b/ios/Sources/RootView.swift index dab2179..eddc581 100644 --- a/ios/Sources/RootView.swift +++ b/ios/Sources/RootView.swift @@ -123,6 +123,7 @@ struct ScanTabView: View { // Der gerade angelegte Artikel, bis sich das Formular geschlossen hat. @State private var angelegt: Product? @State private var einzelstueckeFuer: Product? + @State private var mengeFuer: Product? @State private var showKassenzettel = false var body: some View { @@ -182,6 +183,7 @@ struct ScanTabView: View { .fullScreenCover(isPresented: $showKassenzettel) { KassenzettelView() } .sheet(isPresented: $showNew, onDismiss: { if fuehrtEinzelstuecke(angelegt) { einzelstueckeFuer = angelegt } + else if fuehrtMengeJeLagerort(angelegt) { mengeFuer = angelegt } angelegt = nil }) { NavigationStack { @@ -192,6 +194,7 @@ struct ScanTabView: View { } } .einzelstueckeNachAnlegen($einzelstueckeFuer) + .mengeNachAnlegen($mengeFuer) } } }