Hat ein Artikel ein Bild, zeigt die Produktliste jetzt ein Vorschaubild statt des neutralen shippingbox-Symbols; ohne Bild bleibt das Symbol. Ein gemeinsamer ProductImageCache verhindert, dass beim Scrollen dieselben Bilder immer wieder geladen werden, und merkt sich auch "kein Bild". ProductPhotoSection haelt den Cache bei Laden/Upload/Entfernen synchron. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
337 lines
11 KiB
Swift
337 lines
11 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 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() }
|
||
}
|
||
}
|
||
}
|
||
if items.isEmpty && groups.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()
|
||
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 {
|
||
@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 busy = false
|
||
@State private var showNew = false
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
Section {
|
||
ForEach(products) { product in
|
||
NavigationLink {
|
||
ProductDetailView(product: product)
|
||
} label: {
|
||
ProductRow(product: product)
|
||
}
|
||
}
|
||
if products.isEmpty && !busy {
|
||
Text("Keine Treffer").foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.searchable(text: $query, prompt: "Artikel suchen")
|
||
.onChange(of: query) { _ in Task { await load() } }
|
||
.navigationTitle("Produkte")
|
||
.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. Nach dem Anlegen
|
||
// schließt sich das Formular und die Liste wird aufgefrischt.
|
||
ProductFormView(prefillBarcode: nil, groupId: nil) { _ 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 ids = Set(categories.map(\.id))
|
||
var result: [TreeEntry] = []
|
||
|
||
func walk(_ node: CategoryItem, _ depth: Int) {
|
||
result.append(TreeEntry(item: node, depth: depth))
|
||
for child in categories.filter({ $0.parentId == node.id }) {
|
||
walk(child, depth + 1)
|
||
}
|
||
}
|
||
// Waisen (Oberkategorie geloescht) gelten als oberste Ebene.
|
||
for root in categories.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
|
||
walk(root, 0)
|
||
}
|
||
return result
|
||
}
|
||
|
||
private func load() async {
|
||
busy = true
|
||
defer { busy = false }
|
||
products = (try? await APIClient.shared.searchProducts(query, categoryId: categoryId)) ?? []
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
}
|