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>
568 lines
21 KiB
Swift
568 lines
21 KiB
Swift
import SwiftUI
|
||
|
||
/// Gemeinsame Zahlenformatierung der Listen.
|
||
func formatAmount(_ value: Double) -> String {
|
||
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
|
||
}
|
||
|
||
/// Kennzeichnung "Gruppe" an einer Listenzeile.
|
||
struct GroupBadge: View {
|
||
var body: some View {
|
||
Text("Gruppe")
|
||
.font(.caption2)
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 2)
|
||
.background(Color.secondary.opacity(0.15))
|
||
.clipShape(Capsule())
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
/// Zeile mit Symbol links, damit Artikel und Gruppe unterscheidbar sind.
|
||
struct ListRow<Trailing: View>: View {
|
||
let systemImage: String
|
||
let title: String
|
||
let subtitle: String
|
||
@ViewBuilder var trailing: Trailing
|
||
|
||
var body: some View {
|
||
HStack(spacing: 12) {
|
||
Image(systemName: systemImage)
|
||
.font(.body)
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: 24)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
HStack(spacing: 6) {
|
||
Text(title)
|
||
trailing
|
||
}
|
||
Text(subtitle)
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Einkaufsliste
|
||
|
||
struct ShoppingListView: View {
|
||
@EnvironmentObject private var display: DisplaySettings
|
||
|
||
@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?
|
||
|
||
var body: some View {
|
||
List {
|
||
if let error {
|
||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||
}
|
||
if !items.isEmpty {
|
||
Section("Artikel unter Mindestbestand") {
|
||
ForEach(items) { item in
|
||
ListRow(
|
||
systemImage: "shippingbox",
|
||
title: item.name,
|
||
subtitle: "fehlt \(display.amountText(item.deficit, packageSize: item.packageSize, baseUnit: item.baseUnit))"
|
||
) { EmptyView() }
|
||
}
|
||
}
|
||
}
|
||
if !groups.isEmpty {
|
||
Section("Gruppen unter Mindestbestand") {
|
||
ForEach(groups) { group in
|
||
ListRow(
|
||
systemImage: "square.stack.3d.up",
|
||
title: group.name,
|
||
subtitle: "fehlt \(formatAmount(group.deficit)) \(group.unitName) · \(group.productCount) Artikel"
|
||
) { GroupBadge() }
|
||
}
|
||
}
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
.navigationTitle("Einkaufsliste")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.refreshable { await load() }
|
||
.task { await load() }
|
||
}
|
||
|
||
private func load() async {
|
||
busy = true
|
||
defer { busy = false }
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Bald ablaufend
|
||
|
||
struct ExpiringView: View {
|
||
@EnvironmentObject private var display: DisplaySettings
|
||
|
||
@State private var items: [ExpiringItem] = []
|
||
@State private var busy = true
|
||
@State private var error: String?
|
||
|
||
var body: some View {
|
||
List {
|
||
if let error {
|
||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||
}
|
||
ForEach(items) { item in
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(item.productName)
|
||
Text("MHD \(display.formatBestBefore(item.bestBefore, precision: item.bestBeforePrecision)) · \(articleAmount(item))")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
Text(restText(item.daysLeft))
|
||
.font(.caption).bold()
|
||
.foregroundStyle(item.daysLeft < 0 ? .red : .orange)
|
||
}
|
||
}
|
||
if items.isEmpty && !busy {
|
||
Text("Nichts läuft demnächst ab.").foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.navigationTitle("Bald ablaufend")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.refreshable { await load() }
|
||
.task { await load() }
|
||
}
|
||
|
||
private func load() async {
|
||
busy = true
|
||
defer { busy = false }
|
||
do {
|
||
items = try await APIClient.shared.expiring()
|
||
error = nil
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
/// Gebinde ist die Leitangabe, sonst die Produkteinheit.
|
||
private func articleAmount(_ item: ExpiringItem) -> String {
|
||
if let size = item.packageSize, size > 0 {
|
||
return "\(formatAmount(item.quantity / size)) \(item.packageLabel ?? "Packung")"
|
||
}
|
||
let faktor = item.unitFactor == 0 ? 1 : item.unitFactor
|
||
return "\(formatAmount(item.quantity / faktor)) \(item.unitName)"
|
||
}
|
||
|
||
private func restText(_ days: Int) -> String {
|
||
if days < 0 { return "abgelaufen" }
|
||
if days == 0 { return "heute" }
|
||
return days == 1 ? "1 Tag" : "\(days) Tage"
|
||
}
|
||
}
|
||
|
||
// MARK: - Mindestbestände
|
||
|
||
/// Ü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 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 && rows.isEmpty {
|
||
Text("Noch keine Mindestbestände gesetzt.").foregroundStyle(.secondary)
|
||
}
|
||
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 && rows.isEmpty { ProgressView() } }
|
||
.refreshable { await load() }
|
||
.onAppear { Task { await load() } }
|
||
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||
Button("OK", role: .cancel) {}
|
||
} message: { Text(error ?? "") }
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func zeile(_ r: MinRow) -> some View {
|
||
HStack(spacing: 12) {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
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 r.unter {
|
||
Text("niedrig").font(.caption2)
|
||
.padding(.horizontal, 8).padding(.vertical, 3)
|
||
.background(Color.orange.opacity(0.2), in: Capsule())
|
||
}
|
||
}
|
||
}
|
||
|
||
private func load() async {
|
||
busy = true
|
||
defer { busy = false }
|
||
do {
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Produktliste
|
||
|
||
struct ProductListView: View {
|
||
/// nil = alle, "food" = nur Lebensmittel, "object" = nur Gegenstände.
|
||
/// Getrennte Listen wie im Web (dort über `fixedType`/`p.tracking`).
|
||
var fixedType: String? = nil
|
||
|
||
@EnvironmentObject private var session: Session
|
||
@State private var query = ""
|
||
@State private var products: [Product] = []
|
||
@State private var categories: [CategoryItem] = []
|
||
/// nil = alle, 0 = ohne Kategorie, sonst die ID (Unterkategorien zaehlen mit).
|
||
@State private var categoryId: Int?
|
||
@State private var sortMode: SortMode = .nameAsc
|
||
/// nil = alle; sonst "count"/"individual"/"bulk" (nur in der Gegenstände-Liste).
|
||
@State private var verwaltungFilter: String?
|
||
@State private var busy = false
|
||
@State private var showNew = false
|
||
|
||
enum SortMode: String, CaseIterable, Identifiable {
|
||
case nameAsc = "Name A–Z"
|
||
case nameDesc = "Name Z–A"
|
||
case updated = "Zuletzt geändert"
|
||
var id: String { rawValue }
|
||
}
|
||
|
||
private let verwaltungOptionen: [(String, String)] = [
|
||
("count", "Menge je Lagerort"),
|
||
("individual", "Einzelstücke"),
|
||
("bulk", "Verbrauchsgegenstand"),
|
||
]
|
||
|
||
/// Verwaltungsart eines Gegenstands.
|
||
private func verwaltung(_ p: Product) -> String {
|
||
p.isIndividual ? "individual" : (p.isBulk ? "bulk" : "count")
|
||
}
|
||
|
||
/// Auf den festen Typ und ggf. die Verwaltungsart gefiltert (wie im Web).
|
||
private var sichtbar: [Product] {
|
||
products.filter { p in
|
||
if let fixedType, (p.isObject ? "object" : "food") != fixedType { return false }
|
||
if let vf = verwaltungFilter, verwaltung(p) != vf { return false }
|
||
return true
|
||
}
|
||
}
|
||
|
||
/// Sichtbare Produkte in der gewählten Reihenfolge.
|
||
private var sortiert: [Product] {
|
||
switch sortMode {
|
||
case .nameAsc:
|
||
return sichtbar.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||
case .nameDesc:
|
||
return sichtbar.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedDescending }
|
||
case .updated:
|
||
// ISO-Strings gleichen Formats sind chronologisch vergleichbar; neueste zuerst.
|
||
return sichtbar.sorted { ($0.updatedAt ?? "") > ($1.updatedAt ?? "") }
|
||
}
|
||
}
|
||
|
||
/// Nur Kategorien des Listentyps (Lebensmittel-Liste zeigt nur Food-Kategorien usw.).
|
||
private var typKategorien: [CategoryItem] {
|
||
guard let fixedType else { return categories }
|
||
return categories.filter { ($0.tracking ?? "food") == fixedType }
|
||
}
|
||
|
||
private var titel: String {
|
||
switch fixedType {
|
||
case "food": return "Lebensmittel"
|
||
case "object": return "Gegenstände"
|
||
default: return "Produkte"
|
||
}
|
||
}
|
||
|
||
private var filterLabel: String {
|
||
if categoryId == 0 { return "Ohne Kategorie" }
|
||
if let id = categoryId, let treffer = categories.first(where: { $0.id == id }) {
|
||
return treffer.name
|
||
}
|
||
return "Alle Kategorien"
|
||
}
|
||
|
||
var body: some View {
|
||
List {
|
||
Section {
|
||
Menu {
|
||
Button("Alle Kategorien") { pick(nil) }
|
||
Button("Ohne Kategorie") { pick(0) }
|
||
Divider()
|
||
ForEach(categoryTree()) { eintrag in
|
||
Button(eintrag.label) { pick(eintrag.item.id) }
|
||
}
|
||
} label: {
|
||
HStack {
|
||
Label("Kategorie", systemImage: "line.3.horizontal.decrease.circle")
|
||
Spacer()
|
||
Text(filterLabel).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
if fixedType == "object" {
|
||
Picker(selection: $verwaltungFilter) {
|
||
Text("Alle").tag(String?.none)
|
||
ForEach(verwaltungOptionen, id: \.0) { opt in
|
||
Text(opt.1).tag(String?.some(opt.0))
|
||
}
|
||
} label: {
|
||
Label("Verwaltung", systemImage: "square.grid.2x2")
|
||
}
|
||
.pickerStyle(.menu)
|
||
}
|
||
Picker(selection: $sortMode) {
|
||
ForEach(SortMode.allCases) { Text($0.rawValue).tag($0) }
|
||
} label: {
|
||
Label("Sortierung", systemImage: "arrow.up.arrow.down")
|
||
}
|
||
.pickerStyle(.menu)
|
||
}
|
||
|
||
Section {
|
||
ForEach(sortiert) { product in
|
||
NavigationLink {
|
||
ProductDetailView(product: product)
|
||
} label: {
|
||
ProductRow(product: product)
|
||
}
|
||
}
|
||
if sichtbar.isEmpty && !busy {
|
||
Text("Keine Treffer").foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.searchable(text: $query, prompt: "Artikel suchen")
|
||
.onChange(of: query) { _ in Task { await load() } }
|
||
.navigationTitle(titel)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.refreshable { await load() }
|
||
.toolbar {
|
||
if session.isAdmin {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button { showNew = true } label: {
|
||
Label("Artikel anlegen", systemImage: "plus")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.sheet(isPresented: $showNew) {
|
||
NavigationStack {
|
||
// Ohne Barcode: nur Name + Kategorie nötig. Typ aus der Liste
|
||
// vorbelegen. Nach dem Anlegen schließt sich das Formular und die
|
||
// Liste wird aufgefrischt.
|
||
ProductFormView(prefillBarcode: nil, groupId: nil, initialType: fixedType) { _ in
|
||
showNew = false
|
||
Task { await load() }
|
||
}
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) {
|
||
Button("Abbrechen") { showNew = false }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.task { categories = (try? await APIClient.shared.categories()) ?? [] }
|
||
// Bei jedem Erscheinen neu laden – auch beim Zurückkommen aus der
|
||
// Detailansicht, damit ein dort geänderter Bestand (z.B. neu angelegte
|
||
// Einzelstücke) sofort stimmt und nicht „0" hängen bleibt.
|
||
.onAppear { Task { await load() } }
|
||
}
|
||
|
||
private func pick(_ id: Int?) {
|
||
categoryId = id
|
||
Task { await load() }
|
||
}
|
||
|
||
/// Kategorien in Baumreihenfolge mit eingerueckter Beschriftung.
|
||
private struct TreeEntry: Identifiable {
|
||
let item: CategoryItem
|
||
let depth: Int
|
||
var id: Int { item.id }
|
||
var label: String { String(repeating: " ", count: depth) + item.name }
|
||
}
|
||
|
||
private func categoryTree() -> [TreeEntry] {
|
||
let liste = typKategorien
|
||
let ids = Set(liste.map(\.id))
|
||
var result: [TreeEntry] = []
|
||
|
||
func walk(_ node: CategoryItem, _ depth: Int) {
|
||
result.append(TreeEntry(item: node, depth: depth))
|
||
for child in liste.filter({ $0.parentId == node.id }) {
|
||
walk(child, depth + 1)
|
||
}
|
||
}
|
||
// Waisen (Oberkategorie geloescht) gelten als oberste Ebene.
|
||
for root in liste.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
|
||
walk(root, 0)
|
||
}
|
||
return result
|
||
}
|
||
|
||
private func load() async {
|
||
busy = true
|
||
defer { busy = false }
|
||
let geladen = (try? await APIClient.shared.searchProducts(query, categoryId: categoryId)) ?? []
|
||
products = geladen
|
||
// Bilder im Hintergrund sicherstellen – nur fehlende/geänderte werden geholt,
|
||
// damit die Thumbnails auch nach App-Neustart sofort aus dem Cache kommen.
|
||
Task { await ProductImageCache.shared.preload(geladen) }
|
||
}
|
||
}
|
||
|
||
/// Artikelzeile mit Gebinde und Hinweis auf den Mindestbestand.
|
||
struct ProductRow: View {
|
||
let product: Product
|
||
|
||
var body: some View {
|
||
HStack(spacing: 12) {
|
||
ProductThumb(productId: product.id)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(product.name)
|
||
Text(bestand)
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
if let hinweis = mindestbestand {
|
||
Text(hinweis).font(.caption2).foregroundStyle(farbe)
|
||
}
|
||
}
|
||
Spacer()
|
||
}
|
||
}
|
||
|
||
private var bestand: String {
|
||
var text = "\(formatAmount(product.stockInArticleUnits)) \(product.articleUnitLabel)"
|
||
// Bei einem Gebinde die Basismenge dahinter, damit "2 Glas" greifbar wird.
|
||
if let size = product.packageSize, size > 0 {
|
||
text += " · à \(formatAmount(size)) \(DisplaySettings.baseUnitLabel(product.baseUnit))"
|
||
}
|
||
return text
|
||
}
|
||
|
||
private var mindestbestand: String? {
|
||
guard let anzeige = product.minStockDisplay else { return nil }
|
||
let einheit = product.minStockUnitLabel ?? ""
|
||
switch product.stockLevel {
|
||
case .below: return "unter Mindestbestand (\(formatAmount(anzeige)) \(einheit))"
|
||
case .close: return "bald nachkaufen (Minimum \(formatAmount(anzeige)) \(einheit))"
|
||
case .ok, .none: return nil
|
||
}
|
||
}
|
||
|
||
private var farbe: Color {
|
||
product.stockLevel == .below ? .red : .orange
|
||
}
|
||
}
|