iOS: Mindestbestand je Lagerort (Produkte) + Einkaufsliste je Ort

- Produkt-Detail: neuer Editor „Mindestbestand je Lagerort" (Zeilen: Lagerort +
  Menge in Artikeleinheiten), zusaetzlich zum Gesamt-Mindestbestand.
- Einkaufsliste: Abschnitte „Bedarf: <Lagerort>" 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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-27 07:23:12 +02:00
parent dff7677e50
commit 5d3ae185ce
6 changed files with 247 additions and 1 deletions

View File

@@ -0,0 +1 @@
<svg id="Layer_1" enable-background="new 0 0 500 500" viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m394.394 334.235v-62.008l-53.677 31.007v62.009zm-120.521 0 53.733 31.007v-62.009l-53.733-31.007zm60.288-104.356 53.677 31.002-53.677 31.007-53.733-31.007zm73.344 31.001v77.136c0 2.342-1.233 4.51-3.306 5.681l-66.788 38.56c-2.049 1.189-4.531 1.175-6.555 0l-66.788-38.56c-2.017-1.171-3.306-3.339-3.306-5.681v-77.136c0-2.342 1.289-4.499 3.306-5.67l66.788-38.577c2.017-1.171 4.538-1.171 6.555 0l66.788 38.577c2.073 1.171 3.306 3.328 3.306 5.67zm-241.658-31.001-53.677 31.002 53.677 31.007 53.677-31.007zm60.233 104.356-53.677 31.007v-62.009l53.677-31.007zm-120.465 0v-62.008l53.677 31.007v62.009zm-13.111 3.782v-77.137c0-2.342 1.233-4.499 3.25-5.67l66.844-38.577c2.017-1.171 4.482-1.171 6.555 0l66.788 38.577c2.017 1.171 3.25 3.328 3.25 5.67v77.136c0 2.342-1.233 4.51-3.25 5.681l-66.788 38.56c-2.028 1.177-4.518 1.182-6.555 0l-66.844-38.56c-2.017-1.17-3.25-3.338-3.25-5.68zm157.5-253.245-53.677 30.996 53.677 31.007 53.677-31.007zm60.233 104.351v-62.003l-53.677 31.007v62.003zm-120.465 0 53.677 31.007v-62.003l-53.677-31.007zm-9.861 9.463 66.788 38.565c2.065 1.161 4.512 1.18 6.555 0l66.844-38.565c2.017-1.171 3.25-3.339 3.25-5.676v-77.142c0-2.331-1.233-4.499-3.25-5.67l-66.844-38.565c-2.017-1.171-4.482-1.171-6.555 0l-66.788 38.565c-2.017 1.171-3.25 3.339-3.25 5.67v77.142c0 2.337 1.233 4.505 3.25 5.676zm205.91 216.298-.28 2.264-14.344 42.695c-.952 2.734-3.474 4.466-6.219 4.466-4.443 0-7.617-4.413-6.219-8.64l7.9-23.572c-84.823 54.5-197.289 43.07-269.56-29.192-77.322-77.31-84.773-200.369-17.369-286.246 2.241-2.846 6.388-3.345 9.245-1.109 2.802 2.241 3.306 6.354 1.064 9.2-63.314 80.666-56.31 196.267 16.305 268.888 68.749 68.749 175.99 78.689 255.665 25.841l-28.183-3.636c-3.586-.459-6.107-3.748-5.659-7.329.448-3.592 3.754-6.135 7.34-5.664l44.6 5.737v.006c2.218.442 2.167.629 3.754 1.793l.504.538.112.123c.761 1.255 1.344 2.163 1.344 3.837zm34.402-31.539c-2.245 2.86-6.315 3.324-9.189 1.109-2.857-2.236-3.362-6.359-1.121-9.211 63.37-80.655 56.366-196.25-16.305-268.882-68.693-68.749-175.99-78.694-255.665-25.835l28.239 3.625c3.586.465 6.107 3.748 5.659 7.34-.47 3.604-3.822 6.133-7.34 5.659l-44.656-5.749c-1.283-.325-2.054-.403-3.306-1.429-.112-.106-.224-.23-.392-.342l-.56-.656c-.467-.093-1.457-2.622-1.457-4.102l.336-2.051 14.344-42.661c1.121-3.435 4.875-5.289 8.293-4.135s5.267 4.869 4.146 8.303l-7.9 23.561c84.549-54.349 197.113-43.238 269.56 29.197 77.323 77.318 84.775 200.382 17.314 286.259z" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -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" : {

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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<Int> = []
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
}
}
}