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(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: - 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 @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 } } /// Auf den festen Typ gefiltert (clientseitig, wie im Web). private var sichtbar: [Product] { guard let fixedType else { return products } return products.filter { ($0.isObject ? "object" : "food") == fixedType } } /// 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) } } 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()) ?? [] 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 } }