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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user