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

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