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) } } }