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] = [] /// Selbst draufgesetzte Posten - von Hand oder aus einem Rezept uebernommen. @State private var entries: [ShoppingEntry] = [] @State private var busy = true @State private var error: String? /// Abgehakte verschwinden: Die Liste ist zum Einkaufen da, nicht zum /// Nachlesen. Das Aufraeumen ("Erledigte entfernen") macht die /// Weboberflaeche. private var offeneEintraege: [ShoppingEntry] { entries.filter { !$0.done } } var body: some View { List { if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } if !offeneEintraege.isEmpty { Section("Selbst draufgesetzt") { ForEach(offeneEintraege) { eintrag in Button { Task { await hake(eintrag) } } label: { ListRow( systemImage: eintrag.istNotiz ? "square.and.pencil" : "shippingbox", title: eintrag.name, subtitle: eintrag.untertitel ) { if eintrag.istGruppe { GroupBadge() } } } .buttonStyle(.plain) } } } 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" + ((group.subgroupCount ?? 0) > 0 ? " · inkl. " + anzahlWort(group.subgroupCount ?? 0, "Untergruppe", "Untergruppen") : "") ) { 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 && offeneEintraege.isEmpty && !busy { Text("Nichts einzukaufen – alle Mindestbestände sind gedeckt.") .foregroundStyle(.secondary) } } .navigationTitle("Einkaufsliste") .navigationBarTitleDisplayMode(.inline) .refreshable { await load() } .task { await load() } } /// Abhaken. Der Posten verschwindet sofort; geht das Speichern schief, /// kommt er zurueck - lieber ein Ruecksprung als ein falsches Haekchen. private func hake(_ eintrag: ShoppingEntry) async { let vorher = entries entries.removeAll { $0.id == eintrag.id } do { try await APIClient.shared.setShoppingEntryDone(eintrag.id, done: true) } catch { entries = vorher self.error = error.localizedDescription } } private func load() async { busy = true defer { busy = false } do { // EIN Aufruf statt drei - und der einzige, der die selbst // draufgesetzten Posten mitbringt. Die drei Einzelrouten liefern // nur die gerechneten Bedarfe; wer nur sie abfragt, sieht die // eigenen Eintraege ueberhaupt nicht. let alles = try await APIClient.shared.shoppingAll() items = alles.products groups = alles.groups byLocation = alles.byLocation entries = alles.entries 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("\(item.fristLabel) \(display.formatBestBefore(item.warnDate, precision: item.fristPrecision)) · \(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 – nach LAGERORT gegliedert. /// /// Ein Mindestbestand ist immer ein Bedarf an einem Ort. „Überall" (egal wo, /// Hauptsache im Haus) ist dabei der oberste Ort; Käufe für einen einzelnen /// Lagerort decken ihn mit ab. Mengen kommen in Basiseinheiten und werden hier /// in die Erfassungseinheit des Ziels umgerechnet. /// /// Artikelzeilen öffnen den Artikel, Gruppenzeilen die Gruppe – beide lassen /// sich dort bearbeiten. 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 soll: String let bestand: String? let productId: Int? // Tippziel (Artikel) let groupId: Int? // Tippziel (Gruppe) let unter: Bool let untergruppen: Int } private struct OrtBlock: Identifiable { let id: String let name: String let zeilen: [MinRow] } /// Alle Zeilen, gebündelt je Ort. „Überall" (Ort nil) steht zuerst. private var bloecke: [OrtBlock] { var jeOrt: [String: [MinRow]] = [:] var namen: [String: String] = [UEBERALL_ID: UEBERALL_NAME] func merken(_ locationId: String?, _ locationName: String?, _ row: MinRow) { let key = locationId ?? UEBERALL_ID if let n = locationName, locationId != nil { namen[key] = n } jeOrt[key, default: []].append(row) } for p in products.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) { let disp = p.unitFactor == 0 ? 1 : p.unitFactor // Basis -> Anzeigeeinheit for l in (p.locationMinStocks ?? []) { merken(l.locationId, l.locationName, MinRow( id: "p\(p.id)l\(l.id)", title: p.name, soll: "\(formatAmount(l.minStock / disp)) \(p.unitName)", bestand: l.stock.map { "\(formatAmount($0 / disp)) \(p.unitName)" }, productId: p.id, groupId: nil, unter: (l.stock ?? 0) < l.minStock, untergruppen: 0)) } } for g in groups.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) { let faktor = g.minFaktor == 0 ? 1 : g.minFaktor let einheit = g.minEinheit for l in (g.locationMinStocks ?? []) { merken(l.locationId, l.locationName, MinRow( id: "g\(g.id)l\(l.id)", title: g.name, soll: "\(formatAmount(l.minStock / faktor)) \(einheit)", bestand: l.stock.map { "\(formatAmount($0 / faktor)) \(einheit)" }, productId: nil, groupId: g.id, unter: (l.stock ?? 0) < l.minStock, untergruppen: g.childIds?.count ?? 0)) } } // „Überall" zuerst, danach die Lagerorte alphabetisch. let schluessel = jeOrt.keys.sorted { a, b in if a == UEBERALL_ID { return true } if b == UEBERALL_ID { return false } return (namen[a] ?? "").localizedCaseInsensitiveCompare(namen[b] ?? "") == .orderedAscending } return schluessel.map { OrtBlock(id: $0, name: namen[$0] ?? "Lagerort", zeilen: jeOrt[$0] ?? []) } } var body: some View { List { if !busy && bloecke.isEmpty { Text("Noch keine Mindestbestände gesetzt.").foregroundStyle(.secondary) } ForEach(bloecke) { block in Section(block.id == UEBERALL_ID ? "Überall (egal wo)" : block.name) { ForEach(block.zeilen) { r in if let pid = r.productId, let p = products.first(where: { $0.id == pid }) { NavigationLink { ProductDetailView(product: p) } label: { zeile(r) } } else if let gid = r.groupId, let g = groups.first(where: { $0.id == gid }) { NavigationLink { GroupEditor(item: g, all: groups) { Task { await load() } } } label: { zeile(r) } } else { zeile(r) } } } } } .navigationTitle("Mindestbestände") .navigationBarTitleDisplayMode(.inline) .overlay { if busy && bloecke.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.groupId != nil { Text("Gruppe").font(.caption2) .padding(.horizontal, 6).padding(.vertical, 1) .background(Color.marke.opacity(0.2), in: Capsule()) } if r.untergruppen > 0 { Text("inkl. \(r.untergruppen)").font(.caption2) .padding(.horizontal, 6).padding(.vertical, 1) .background(Color.secondary.opacity(0.15), in: Capsule()) } } Text("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 // Der gerade angelegte Artikel, bis sich das Formular geschlossen hat. @State private var angelegt: Product? /// Der gerade angelegte Artikel, bis der Folgeschritt dafuer durch ist. @State private var nachAnlegenFuer: Product? 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, onDismiss: { if fuehrtEinzelstuecke(angelegt) || fuehrtMengeJeLagerort(angelegt) { nachAnlegenFuer = angelegt } angelegt = nil }) { NavigationStack { // Ohne Barcode: nur Name + Kategorie nötig. Typ aus der Liste // vorbelegen. Nach dem Anlegen schließt sich das Formular, die // Liste wird aufgefrischt – und bei Gegenständen geht es gleich // weiter: zu den Einzelstücken, oder zur Menge je Lagerort. ProductFormView(prefillBarcode: nil, groupId: nil, initialType: fixedType) { created in angelegt = created showNew = false Task { await load() } } .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { showNew = false } } } } } .nachAnlegen($nachAnlegenFuer) .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))" } // Marke voran: Die Suche findet Artikel ueber sie, ohne sie in der // Zeile bliebe ein Treffer auf "Jack and Jones" unerklaerlich. if let marke = product.brand, !marke.isEmpty { text = "\(marke) · \(text)" } 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 } } // MARK: - Chargen /// Alle Chargen über alle Artikel – das Gegenstück zur Chargenliste im Web. /// /// Hier passiert das Anbrechen und der Teilverbrauch. Beides gab es in der App /// bisher nirgends: Chargen waren nur im Artikel selbst zu sehen, und dort nur /// zum Korrigieren. struct ChargenListView: View { @EnvironmentObject private var display: DisplaySettings @State private var zeilen: [LotRow] = [] @State private var locations: [StorageLocation] = [] @State private var suche = "" @State private var nurAngebrochen = false @State private var verbrauchen: LotRow? @State private var anbrechen: LotRow? @State private var ansehen: LotRow? @State private var busy = true @State private var error: String? @State private var hinweis: String? private var gefiltert: [LotRow] { zeilen.filter { zeile in if nurAngebrochen && !zeile.istAngebrochen { return false } guard !suche.isEmpty else { return true } let text = "\(zeile.productName) \(zeile.productBrand ?? "")".lowercased() return text.contains(suche.lowercased()) } } var body: some View { List { if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } if let hinweis { Section { Text(hinweis).foregroundStyle(.green).font(.callout) } } Section { Toggle("Nur angebrochene", isOn: $nurAngebrochen) } Section("\(gefiltert.count) Chargen") { ForEach(gefiltert) { zeile in // Antippen oeffnet die Charge. Das Wischen allein genuegte // nicht: Man sieht es nicht, und man kam gar nicht an die // Angaben heran. Button { ansehen = zeile } label: { HStack { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(zeile.productName) if zeile.istAngebrochen { Text("angebrochen") .font(.caption2) .padding(.horizontal, 6).padding(.vertical, 2) .background(Color.accentColor.opacity(0.15)) .clipShape(Capsule()) } } Text(untertitel(zeile)) .font(.caption).foregroundStyle(.secondary) } Spacer() Image(systemName: "chevron.right").foregroundStyle(.tertiary) } } .foregroundStyle(.primary) // Nach links wischen: die beiden Buchungen. Loeschen gibt es // hier bewusst nicht - eine Charge verschwindet durchs // Verbrauchen, und das Korrigieren sitzt beim Artikel. .swipeActions(edge: .trailing) { Button { verbrauchen = zeile } label: { Label("Verbrauchen", systemImage: "minus.circle") } .tint(.orange) if !zeile.istAngebrochen { Button { anbrechen = zeile } label: { Label("Anbrechen", systemImage: "arrow.up.bin") } .tint(.blue) } } } if gefiltert.isEmpty && !busy { Text(nurAngebrochen ? "Keine angebrochene Packung." : "Keine Chargen im Bestand.") .foregroundStyle(.secondary) } } } .searchable(text: $suche, prompt: "Artikel suchen") .navigationTitle("Chargen") .navigationBarTitleDisplayMode(.inline) .refreshable { await load() } .task { await load() } .sheet(item: $ansehen) { zeile in NavigationStack { ChargeDetailSheet(zeile: zeile, locations: locations) { meldung in hinweis = meldung Task { await load() } } } } .sheet(item: $verbrauchen) { zeile in NavigationStack { VerbrauchSheet(zeile: zeile) { meldung in hinweis = meldung Task { await load() } } } } .confirmationDialog("Eine Packung anbrechen?", isPresented: Binding(get: { anbrechen != nil }, set: { if !$0 { anbrechen = nil } }), titleVisibility: .visible) { Button("Anbrechen") { if let zeile = anbrechen { Task { await brichAn(zeile) } } anbrechen = nil } Button("Abbrechen", role: .cancel) { anbrechen = nil } } message: { Text("Eine Packung wird als eigene, offene Charge geführt und bekommt ein Verbrauchsdatum. Der Rest bleibt ungeöffnet mit seinem MHD.") } } private func untertitel(_ zeile: LotRow) -> String { var teile = [zeile.mengeText] if zeile.istAngebrochen, let start = zeile.openedQuantity { teile.append("von \(formatAmount(start)) \(zeile.baseKurz)") } if let bis = zeile.consumeBy { teile.append("verbrauchen bis \(display.formatBestBefore(bis, precision: "day"))") } else if zeile.bestBefore != nil { teile.append("MHD \(display.formatBestBefore(zeile.bestBefore, precision: zeile.bestBeforePrecision))") } if let id = zeile.locationId, let ort = locations.first(where: { $0.id == id }) { teile.append(ort.path(in: locations)) } return teile.joined(separator: " · ") } private func brichAn(_ zeile: LotRow) async { do { let offen = try await APIClient.shared.openLot(id: zeile.id) if let bis = offen.consumeBy { hinweis = "Angebrochen – zu verbrauchen bis \(display.formatBestBefore(bis, precision: "day"))." } else { hinweis = "Angebrochen." } error = nil await load() } catch { self.error = error.localizedDescription } } private func load() async { busy = true defer { busy = false } do { zeilen = try await APIClient.shared.allLotRows() locations = (try? await APIClient.shared.locations()) ?? [] error = nil } catch { self.error = error.localizedDescription } } } /// Teilmenge aus einer Charge verbrauchen – „100 ml genommen“, „2 EL Mehl“. /// /// Eingetippt wird die VERBRAUCHTE Menge, nicht der Rest: Das ist die Zahl, die /// man kennt. War die Charge noch zu, wird sie dabei angebrochen; der Haken /// dafür ist abwählbar, falls man nur eine Buchung nachträgt. struct VerbrauchSheet: View { let zeile: LotRow var onDone: (String) -> Void @Environment(\.dismiss) private var dismiss @State private var menge = "" @State private var einheit = "" @State private var notiz = "" @State private var alsAngebrochen = true @State private var masse: [MeasureAmount] = [] @State private var busy = false @State private var error: String? /// Alles, was als Einheit gewählt werden kann: Basiseinheit, Gebinde und /// die Hausmaße der Kategorie. private var einheiten: [(token: String, label: String)] { var liste: [(token: String, label: String)] = [(zeile.baseToken, zeile.baseKurz)] if let p = zeile.packageSize, p > 0 { let name = zeile.packageLabel ?? "Packung" liste.append(("package", "\(name) (\(formatAmount(p)) \(zeile.baseKurz))")) } for mass in masse { let kurz = mass.short.map { " (\($0))" } ?? "" liste.append((mass.token, "\(mass.name)\(kurz) ≈ \(formatAmount(mass.amount)) \(kurzFuer(mass.base))")) } return liste } private func kurzFuer(_ base: String) -> String { switch base { case "gram": return "g" case "milliliter": return "ml" default: return "Stück" } } private var zahl: Double? { Double(menge.replacingOccurrences(of: ",", with: ".")) } var body: some View { Form { if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } Section { Text(zeile.productName).font(.headline) Text("aktuell \(zeile.mengeText)").font(.caption).foregroundStyle(.secondary) } Section("Verbrauchte Menge") { QuantityField(label: "Menge", text: $menge) Picker("Einheit", selection: $einheit) { ForEach(einheiten, id: \.token) { eintrag in Text(eintrag.label).tag(eintrag.token) } } } if masse.isEmpty { Section { // Das schließende Anführungszeichen MUSS das typografische // sein: Ein gerades " würde die Zeichenkette hier beenden. Text("Für die Kategorie dieses Artikels ist kein Hausmaß hinterlegt – „2 EL“ lässt sich deshalb noch nicht buchen. Mengen dafür stehen in der Weboberfläche unter Kategorien.") .font(.caption).foregroundStyle(.secondary) } } if !zeile.istAngebrochen { Section { Toggle("Packung als angebrochen führen", isOn: $alsAngebrochen) } footer: { Text("Eine Packung wird abgespalten und bekommt ein Verbrauchsdatum; der Rest behält sein MHD. Ohne Haken wird nur die Menge abgebucht.") } } Section("Notiz (freiwillig)") { TextField("z.B. für Pfannkuchen", text: $notiz) } Section { Button(busy ? "Wird gebucht…" : "Verbrauchen") { Task { await buche() } } .disabled(busy || zahl == nil || (zahl ?? 0) <= 0 || einheit.isEmpty) } } .navigationTitle("Verbrauchen") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } } .task { einheit = zeile.baseToken masse = (try? await APIClient.shared.productMeasures(productId: zeile.productId)) ?? [] } } private func buche() async { guard let wert = zahl, wert > 0 else { return } busy = true defer { busy = false } do { let antwort = try await APIClient.shared.consumeLot( id: zeile.id, quantity: wert, unit: einheit, note: notiz.isEmpty ? nil : notiz, autoOpen: zeile.istAngebrochen ? false : alsAngebrochen) dismiss() if antwort.lot == nil { onDone("Verbraucht – die Charge ist damit leer.") } else { onDone(antwort.opened ? "Verbraucht und angebrochen." : "Verbraucht.") } } catch { self.error = error.localizedDescription } } } /// Eine Charge ansehen – und von hier aus anbrechen oder verbrauchen. /// /// Das Wischen in der Liste allein genügt nicht: Man sieht es nicht, und man /// sieht dort auch nicht, was an der Charge dranhängt. Hier stehen alle Angaben /// und beide Handlungen als sichtbare Knöpfe. struct ChargeDetailSheet: View { let zeile: LotRow let locations: [StorageLocation] /// Meldung nach einer Buchung – die Liste zeigt sie an und lädt neu. var onDone: (String) -> Void @EnvironmentObject private var display: DisplaySettings @Environment(\.dismiss) private var dismiss @State private var anbrechenGefragt = false @State private var busy = false @State private var error: String? private var ortText: String { guard let id = zeile.locationId, let ort = locations.first(where: { $0.id == id }) else { return "ohne Lagerort" } return ort.path(in: locations) } var body: some View { Form { if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } Section { Text(zeile.productName).font(.headline) if let marke = zeile.productBrand, !marke.isEmpty { Text(marke).font(.caption).foregroundStyle(.secondary) } } Section("Bestand") { LabeledContent("Menge", value: zeile.mengeText) if zeile.istAngebrochen, let start = zeile.openedQuantity { LabeledContent("Beim Öffnen", value: "\(formatAmount(start)) \(zeile.baseKurz)") } LabeledContent("Zustand", value: zeile.istAngebrochen ? "angebrochen" : "ungeöffnet") LabeledContent("Lagerort", value: ortText) if let seit = zeile.createdAt { LabeledContent("Eingelagert", value: display.formatBestBefore(String(seit.prefix(10)), precision: "day")) } } Section("Fristen") { LabeledContent("MHD", value: zeile.bestBefore == nil ? "ohne MHD" : display.formatBestBefore(zeile.bestBefore, precision: zeile.bestBeforePrecision)) if let geoeffnet = zeile.openedOn { LabeledContent("Angebrochen am", value: display.formatBestBefore(geoeffnet, precision: "day")) } if let bis = zeile.consumeBy { LabeledContent("Verbrauchen bis", value: display.formatBestBefore(bis, precision: "day")) } } Section { // Bewusst ein Weiterblaettern und kein zweites Fenster: Ein // Sheet im Sheet aufzuziehen und beide gleich wieder zu // schliessen ist in SwiftUI eine zuverlaessige Quelle fuer // haengende Ansichten. NavigationLink { VerbrauchSheet(zeile: zeile) { meldung in dismiss() onDone(meldung) } } label: { Label("Verbrauchen", systemImage: "minus.circle") } if !zeile.istAngebrochen { Button { anbrechenGefragt = true } label: { Label("Anbrechen", systemImage: "arrow.up.bin") } .disabled(busy) } } footer: { Text(zeile.istAngebrochen ? "Diese Packung ist offen. Was du verbrauchst, geht von ihr ab." : "Anbrechen spaltet eine Packung ab und gibt ihr ein Verbrauchsdatum; " + "der Rest bleibt ungeöffnet mit seinem MHD.") } Section { // Eine einzelne Charge hat keinen eigenen Verlauf im Backend; // die Bewegungen haengen am Artikel. Das ist hier die naechste // Frage nach "was ist das" - "was ist damit passiert". NavigationLink { HistoryView(productId: zeile.productId) } label: { Label("Verlauf dieses Artikels", systemImage: "clock.arrow.circlepath") } } } .navigationTitle("Charge") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } } } .confirmationDialog("Eine Packung anbrechen?", isPresented: $anbrechenGefragt, titleVisibility: .visible) { Button("Anbrechen") { Task { await brichAn() } } Button("Abbrechen", role: .cancel) {} } message: { Text("Eine Packung wird als eigene, offene Charge geführt und bekommt ein Verbrauchsdatum. Der Rest bleibt ungeöffnet mit seinem MHD.") } } private func brichAn() async { busy = true defer { busy = false } do { let offen = try await APIClient.shared.openLot(id: zeile.id) dismiss() if let bis = offen.consumeBy { onDone("Angebrochen – zu verbrauchen bis \(display.formatBestBefore(bis, precision: "day")).") } else { onDone("Angebrochen.") } } catch { self.error = error.localizedDescription } } }