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: 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(item.restlaufzeitText) .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)" } } // 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.marke.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 } }