From 5d3ae185cec96050ac50eaff47a4f93e50cb546d Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 27 Jul 2026 07:23:12 +0200 Subject: [PATCH] iOS: Mindestbestand je Lagerort (Produkte) + Einkaufsliste je Ort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Produkt-Detail: neuer Editor „Mindestbestand je Lagerort" (Zeilen: Lagerort + Menge in Artikeleinheiten), zusaetzlich zum Gesamt-Mindestbestand. - Einkaufsliste: Abschnitte „Bedarf: " mit Produkten und Gruppen, die am Ort unter dem dort hinterlegten Mindestbestand liegen. - Models/Client: LocationMinStock(+In), LocationNeeds*, setProductLocationMinStock, shoppingByLocation; Product um location_min_stocks erweitert. (Gruppen-Bedarf je Ort ist vorerst nur im Web pflegbar, wird aber in der iOS- Einkaufsliste mit angezeigt.) Co-Authored-By: Claude Opus 4.8 --- ios/AppIcon.icon/Assets/stock-rotation.svg | 1 + ios/AppIcon.icon/icon.json | 26 +++++ ios/Sources/APIClient.swift | 11 +++ ios/Sources/ListViews.swift | 22 ++++- ios/Sources/Models.swift | 81 ++++++++++++++++ ios/Sources/ProductDetailView.swift | 107 +++++++++++++++++++++ 6 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 ios/AppIcon.icon/Assets/stock-rotation.svg diff --git a/ios/AppIcon.icon/Assets/stock-rotation.svg b/ios/AppIcon.icon/Assets/stock-rotation.svg new file mode 100644 index 0000000..29a0e44 --- /dev/null +++ b/ios/AppIcon.icon/Assets/stock-rotation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ios/AppIcon.icon/icon.json b/ios/AppIcon.icon/icon.json index 4f8e158..2f70ec1 100644 --- a/ios/AppIcon.icon/icon.json +++ b/ios/AppIcon.icon/icon.json @@ -35,6 +35,31 @@ "groups" : [ { "layers" : [ + { + "fill-specializations" : [ + { + "value" : { + "solid" : "display-p3:0.00000,0.00000,0.00000,1.00000" + } + }, + { + "appearance" : "dark", + "value" : { + "solid" : "display-p3:0.58245,0.53181,0.88393,1.00000" + } + } + ], + "glass" : true, + "image-name" : "stock-rotation.svg", + "name" : "stock-rotation", + "position" : { + "scale" : 1.7, + "translation-in-points" : [ + 0, + 0 + ] + } + }, { "fill-specializations" : [ { @@ -51,6 +76,7 @@ } ], "glass" : false, + "hidden" : true, "image-name" : "barcode-gelesen.svg", "name" : "barcode-gelesen", "position" : { diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index d514683..4171bc5 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -121,6 +121,13 @@ actor APIClient { return try await send(request, as: Product.self) } + /// 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") + try jsonBody(&request, list) + return try await send(request, as: Product.self) + } + /// Aktuelles Artikelfoto laden (mit Anmeldung). Gibt nil bei 404 zurück. func productImage(id: Int) async throws -> Data? { let request = try makeRequest("/products/\(id)/image") @@ -244,6 +251,10 @@ actor APIClient { try await send(try makeRequest("/shopping-list/groups"), as: [GroupShoppingItem].self) } + func shoppingByLocation() async throws -> [LocationNeeds] { + try await send(try makeRequest("/shopping-list/by-location"), as: [LocationNeeds].self) + } + func expiring(days: Int? = nil) async throws -> [ExpiringItem] { let path = days.map { "/expiring?days=\($0)" } ?? "/expiring" return try await send(try makeRequest(path), as: [ExpiringItem].self) diff --git a/ios/Sources/ListViews.swift b/ios/Sources/ListViews.swift index c6bf8f6..98d9114 100644 --- a/ios/Sources/ListViews.swift +++ b/ios/Sources/ListViews.swift @@ -50,6 +50,7 @@ struct ShoppingListView: View { @State private var items: [ShoppingItem] = [] @State private var groups: [GroupShoppingItem] = [] + @State private var byLocation: [LocationNeeds] = [] @State private var busy = true @State private var error: String? @@ -80,7 +81,25 @@ struct ShoppingListView: View { } } } - if items.isEmpty && groups.isEmpty && !busy { + ForEach(byLocation) { ort in + Section("Bedarf: \(ort.locationName)") { + ForEach(ort.products) { p in + ListRow( + systemImage: "shippingbox", + title: p.name, + subtitle: "fehlt \(formatAmount(p.deficit)) \(p.unitLabel)" + ) { EmptyView() } + } + ForEach(ort.groups) { g in + ListRow( + systemImage: "square.stack.3d.up", + title: g.name, + subtitle: "fehlt \(formatAmount(g.deficit)) \(g.unitName)" + ) { GroupBadge() } + } + } + } + if items.isEmpty && groups.isEmpty && byLocation.isEmpty && !busy { Text("Nichts einzukaufen – alle Mindestbestände sind gedeckt.") .foregroundStyle(.secondary) } @@ -97,6 +116,7 @@ struct ShoppingListView: View { do { items = try await APIClient.shared.shoppingList() groups = try await APIClient.shared.shoppingGroups() + byLocation = try await APIClient.shared.shoppingByLocation() error = nil } catch { self.error = error.localizedDescription diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index 5469764..e45a813 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -76,9 +76,12 @@ struct Product: Codable, Identifiable, Hashable { let fieldValues: [String: String?]? /// Gegenstand als Einzelstücke (Items mit UID/QR) statt als Menge geführt. let individual: Bool? + /// Mindestbestand je Lagerort (zusaetzlich zum globalen Mindestbestand). + let locationMinStocks: [LocationMinStock]? enum CodingKeys: String, CodingKey { case id, barcode, name, brand, stock, kind, barcodes, tracking, individual + case locationMinStocks = "location_min_stocks" case datePrecision = "date_precision" case categoryId = "category_id" case categoryName = "category_name" @@ -129,6 +132,31 @@ struct Product: Codable, Identifiable, Hashable { } } +/// Mindestbestand eines Produkts/einer Gruppe an einem Lagerort (Anzeige). +struct LocationMinStock: Codable, Hashable, Identifiable { + let locationId: Int + let locationName: String? + let minStock: Double + var id: Int { locationId } + + enum CodingKeys: String, CodingKey { + case minStock = "min_stock" + case locationId = "location_id" + case locationName = "location_name" + } +} + +/// Ein Mindestbestand-Eintrag zum Speichern (Menge in Artikeleinheiten). +struct LocationMinStockIn: Codable { + let locationId: Int + let minStock: Double + + enum CodingKeys: String, CodingKey { + case minStock = "min_stock" + case locationId = "location_id" + } +} + /// Auswahleintrag fuer Einheiten. /// /// Anzeige und uebertragener Wert sind bewusst getrennt: Das Backend kennt @@ -324,6 +352,59 @@ struct GroupShoppingItem: Codable, Identifiable { } } +// MARK: - Bedarfe je Lagerort + +struct LocationNeedProduct: Codable, Identifiable { + let productId: Int + let name: String + let unitLabel: String + let stock: Double + let minStock: Double + let deficit: Double + + var id: Int { productId } + + enum CodingKeys: String, CodingKey { + case name, stock, deficit + case productId = "product_id" + case unitLabel = "unit_label" + case minStock = "min_stock" + } +} + +struct LocationNeedGroup: Codable, Identifiable { + let groupId: Int + let name: String + let unitName: String + let stock: Double + let minStock: Double + let deficit: Double + + var id: Int { groupId } + + enum CodingKeys: String, CodingKey { + case name, stock, deficit + case groupId = "group_id" + case unitName = "unit_name" + case minStock = "min_stock" + } +} + +struct LocationNeeds: Codable, Identifiable { + let locationId: Int + let locationName: String + let products: [LocationNeedProduct] + let groups: [LocationNeedGroup] + + var id: Int { locationId } + + enum CodingKeys: String, CodingKey { + case products, groups + case locationId = "location_id" + case locationName = "location_name" + } +} + struct ExpiringItem: Codable, Identifiable { let lotId: Int let productId: Int diff --git a/ios/Sources/ProductDetailView.swift b/ios/Sources/ProductDetailView.swift index 93d27c0..6646740 100644 --- a/ios/Sources/ProductDetailView.swift +++ b/ios/Sources/ProductDetailView.swift @@ -132,6 +132,18 @@ struct ProductDetailView: View { } } + Section { + NavigationLink { + ProductLocationMinView(product: current) { await reload() } + } label: { + let n = (current.locationMinStocks ?? []).count + Label(n > 0 ? "Mindestbestand je Lagerort (\(n))" : "Mindestbestand je Lagerort", + systemImage: "mappin.and.ellipse") + } + } footer: { + Text("Bedarf je Lagerort (z.B. Ferienhaus, Zuhause), zusätzlich zum Gesamt-Mindestbestand.") + } + Section("Erkennung") { LabeledContent("Barcode", value: current.barcode ?? "–") if !current.barcodes.isEmpty { @@ -399,3 +411,98 @@ struct LotEditView: View { } } } + +// MARK: - Mindestbestand je Lagerort + +/// Bedarf eines Produkts je Lagerort bearbeiten (zusätzlich zum globalen +/// Mindestbestand). Menge in Artikeleinheiten. +struct ProductLocationMinView: View { + let product: Product + var onChanged: (() async -> Void)? = nil + + @Environment(\.dismiss) private var dismiss + + private struct MinRow: Identifiable { + let id = UUID() + var locationId: Int? + var amount: String + } + + @State private var locations: [StorageLocation] = [] + @State private var rows: [MinRow] = [] + @State private var busy = false + @State private var error: String? + + var body: some View { + Form { + Section { + Text("Bedarf je Lagerort (z.B. Ferienhaus, Zuhause). Menge in \(product.articleUnitLabel).") + .font(.caption).foregroundStyle(.secondary) + } + ForEach($rows) { $row in + HStack { + Picker("Lagerort", selection: $row.locationId) { + Text("– wählen –").tag(Int?.none) + ForEach(locations) { l in Text(l.name).tag(Int?.some(l.id)) } + } + TextField("Menge", text: $row.amount) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 70) + Button(role: .destructive) { + rows.removeAll { $0.id == row.id } + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + } + } + Button { + rows.append(MinRow(locationId: nil, amount: "")) + } label: { + Label("Lagerort hinzufügen", systemImage: "plus") + } + if let error { + Section { Text(error).foregroundStyle(.red).font(.callout) } + } + } + .navigationTitle("Bedarf je Lagerort") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(busy ? "Speichern…" : "Speichern") { Task { await save() } } + .disabled(busy) + } + } + .task { await load() } + } + + private func load() async { + locations = (try? await APIClient.shared.locations()) ?? [] + rows = (product.locationMinStocks ?? []).map { + MinRow(locationId: $0.locationId, amount: formatAmount($0.minStock)) + } + } + + private func save() async { + busy = true + defer { busy = false } + var list: [LocationMinStockIn] = [] + var gesehen: Set = [] + for row in rows { + guard let loc = row.locationId, !gesehen.contains(loc) else { continue } + let wert = Double(row.amount.replacingOccurrences(of: ",", with: ".")) ?? 0 + if wert > 0 { + gesehen.insert(loc) + list.append(LocationMinStockIn(locationId: loc, minStock: wert)) + } + } + do { + _ = try await APIClient.shared.setProductLocationMinStock(id: product.id, list) + await onChanged?() + dismiss() + } catch { + self.error = error.localizedDescription + } + } +}