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