iOS: Mindestbestaende-Uebersicht zeigt Gruppen und je-Lagerort

Bisher nur globale Produkt-Mindestbestaende - wer (wie im Screenshot) nur Gruppen-
oder Lagerort-Bedarfe hatte, sah "keine gesetzt". Jetzt Produkte UND Gruppen,
jeweils Gesamt und je Lagerort, mit Soll (Anzeige-/Gruppeneinheit) und "niedrig"-
Hinweis. Produktzeilen oeffnen den Artikel zum Bearbeiten. GroupItem traegt dafuer
die Mindestbestand-Felder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-29 16:50:11 +02:00
parent a04315db21
commit f29bbced8d
2 changed files with 92 additions and 23 deletions

View File

@@ -190,31 +190,84 @@ struct ExpiringView: View {
// MARK: - Mindestbestände
/// Übersicht aller Artikel mit gesetztem Mindestbestand (Soll vs. Bestand, in der
/// Anzeigeeinheit). Tippen öffnet den Artikel zum Bearbeiten des Mindestbestands.
/// Übersicht aller Mindestbestände: Produkte und Gruppen, jeweils Gesamt UND je
/// Lagerort (Soll in der Anzeige-/Gruppeneinheit). Produktzeilen öffnen den
/// Artikel zum Bearbeiten; das Setzen von Gruppen-/Lagerort-Bedarfen läuft übers
/// Web.
struct MinStockView: View {
@State private var products: [Product] = []
@State private var groups: [GroupItem] = []
@State private var busy = true
@State private var error: String?
private var mitBestand: [Product] {
products.filter { ($0.minStock ?? 0) > 0 }
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
private struct MinRow: Identifiable {
let id: String
let title: String
let ort: String // "Gesamt" oder Lagerort-Name
let soll: String
let bestand: String?
let productId: Int? // Tippziel (nur Produkte)
let isGroup: Bool
let unter: Bool
}
private var rows: [MinRow] {
var out: [MinRow] = []
for p in products.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) {
let disp = p.unitFactor == 0 ? 1 : p.unitFactor // Basis -> Anzeigeeinheit
let art = p.articleUnitFactor == 0 ? 1 : p.articleUnitFactor
if let ms = p.minStock, ms > 0 {
out.append(MinRow(
id: "p\(p.id)g", title: p.name, ort: "Gesamt",
soll: "\(formatAmount(ms / disp)) \(p.unitName)",
bestand: "\(formatAmount(p.stock / disp)) \(p.unitName)",
productId: p.id, isGroup: false, unter: p.stock < ms))
}
for l in (p.locationMinStocks ?? []) {
// je Lagerort in Artikeleinheiten gespeichert -> Anzeigeeinheit.
out.append(MinRow(
id: "p\(p.id)l\(l.locationId)", title: p.name,
ort: l.locationName ?? "Lagerort",
soll: "\(formatAmount(l.minStock * art / disp)) \(p.unitName)",
bestand: nil, productId: p.id, isGroup: false, unter: false))
}
}
for g in groups.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) {
let unit = g.minStockUnitName ?? ""
if let ms = g.minStock, ms > 0 {
out.append(MinRow(
id: "g\(g.id)g", title: g.name, ort: "Gesamt",
soll: "\(formatAmount(ms)) \(unit)",
bestand: g.stock.map { "\(formatAmount($0)) \(unit)" },
productId: nil, isGroup: true, unter: (g.stock ?? 0) < ms))
}
for l in (g.locationMinStocks ?? []) {
out.append(MinRow(
id: "g\(g.id)l\(l.locationId)", title: g.name,
ort: l.locationName ?? "Lagerort",
soll: "\(formatAmount(l.minStock)) \(unit)",
bestand: nil, productId: nil, isGroup: true, unter: false))
}
}
return out
}
var body: some View {
List {
if !busy && mitBestand.isEmpty {
Text("Noch keine Mindestbestände gesetzt. Im Artikel beim Feld Mindestbestand festlegen.")
.foregroundStyle(.secondary)
if !busy && rows.isEmpty {
Text("Noch keine Mindestbestände gesetzt.").foregroundStyle(.secondary)
}
ForEach(mitBestand) { p in
NavigationLink { ProductDetailView(product: p) } label: { row(p) }
ForEach(rows) { r in
if let pid = r.productId, let p = products.first(where: { $0.id == pid }) {
NavigationLink { ProductDetailView(product: p) } label: { zeile(r) }
} else {
zeile(r)
}
}
}
.navigationTitle("Mindestbestände")
.navigationBarTitleDisplayMode(.inline)
.overlay { if busy && products.isEmpty { ProgressView() } }
.overlay { if busy && rows.isEmpty { ProgressView() } }
.refreshable { await load() }
.onAppear { Task { await load() } }
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
@@ -223,20 +276,22 @@ struct MinStockView: View {
}
@ViewBuilder
private func row(_ p: Product) -> some View {
let faktor = p.unitFactor == 0 ? 1 : p.unitFactor
let soll = (p.minStock ?? 0) / faktor
let bestand = p.stock / faktor
let unter = p.stock < (p.minStock ?? 0)
private func zeile(_ r: MinRow) -> some View {
HStack(spacing: 12) {
ProductThumb(productId: p.id)
VStack(alignment: .leading, spacing: 2) {
Text(p.name)
Text("Soll \(formatAmount(soll)) · Bestand \(formatAmount(bestand)) \(p.unitName)")
HStack(spacing: 6) {
Text(r.title)
if r.isGroup {
Text("Gruppe").font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 1)
.background(Color.accentColor.opacity(0.2), in: Capsule())
}
}
Text("\(r.ort) · Soll \(r.soll)" + (r.bestand.map { " · Bestand \($0)" } ?? ""))
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
if unter {
if r.unter {
Text("niedrig").font(.caption2)
.padding(.horizontal, 8).padding(.vertical, 3)
.background(Color.orange.opacity(0.2), in: Capsule())
@@ -248,9 +303,11 @@ struct MinStockView: View {
busy = true
defer { busy = false }
do {
let geladen = try await APIClient.shared.searchProducts("")
products = geladen
Task { await ProductImageCache.shared.preload(geladen) }
async let ps = APIClient.shared.searchProducts("")
async let gs = APIClient.shared.groups()
products = try await ps
groups = try await gs
Task { await ProductImageCache.shared.preload(products) }
} catch {
self.error = error.localizedDescription
}

View File

@@ -586,6 +586,18 @@ struct SettingEntry: Codable {
struct GroupItem: Codable, Identifiable, Hashable {
let id: Int
let name: String
// Mindestbestand-Infos (nur aus /groups befuellt; Picker brauchen sie nicht).
var minStock: Double? = nil
var minStockUnitName: String? = nil
var stock: Double? = nil
var locationMinStocks: [LocationMinStock]? = nil
enum CodingKeys: String, CodingKey {
case id, name, stock
case minStock = "min_stock"
case minStockUnitName = "min_stock_unit_name"
case locationMinStocks = "location_min_stocks"
}
}
/// Kategorie: ordnet die Artikelliste und bestimmt die Verwaltungsart