Files
Vorrania/ios/Sources/ListViews.swift
T
ScarriffleandClaude Opus 5 c860c82912 App: Chargenliste, Anbrechen und Teilverbrauch
In der App gab es beides nicht. Chargen waren ueberhaupt nur innerhalb
eines Artikels zu sehen, und dort nur zum Korrigieren - eine
Chargenliste wie im Web fehlte, und damit jeder Weg, eine Packung
anzubrechen oder eine Teilmenge zu verbrauchen. Das Backend kann das
seit zwei Wochen.

Neu im Tab "Listen": der Eintrag "Chargen" mit allen Chargen ueber alle
Artikel, durchsuchbar, mit Schalter "Nur angebrochene". Je Zeile stehen
Menge, MHD bzw. Verbrauchsdatum, Lagerort und ein Abzeichen
"angebrochen"; bei einer offenen Packung die Restmenge in Basiseinheiten
("980 g von 1.000 g"), denn "0,98 Packungen" sagt niemandem etwas.

Nach links wischen gibt die zwei Buchungen:

"Anbrechen" mit Rueckfrage - eine Packung wird abgespalten und bekommt
ihr Verbrauchsdatum, der Rest bleibt zu und behaelt sein MHD.

"Verbrauchen" oeffnet ein Fenster fuer die VERBRAUCHTE Menge (nicht den
Rest - das ist die Zahl, die man kennt). Die Einheitenauswahl holt die
Hausmasse der Kategorie dazu, "2 EL" ist damit buchbar und landet bei
Mehl als 20 g. War die Charge noch zu, bricht die Buchung sie an; der
Haken dafuer ist abwaehlbar.

Loeschen fehlt in der Liste mit Absicht: Eine Charge verschwindet durchs
Verbrauchen, und das Korrigieren sitzt beim Artikel.

Dort zeigt die Chargenzeile jetzt auch "angebrochen · verbrauchen bis
..." - zwei Zeilen mit gleicher Menge waren sonst nicht zu
unterscheiden.

Alles in bestehende Dateien gelegt, keine neue Quelldatei: Das
mitgelieferte Xcode-Projekt listet die Dateien einzeln, eine neue waere
ohne "xcodegen generate" stillschweigend nicht mitgebaut worden.

Gegen den laufenden Server geprueft, genau mit den Aufrufen, die die App
macht: /lots/rows liefert alle Felder, die das Swift-Modell liest;
{"quantity":null} an /lots/{id}/open spaltet 1000 von 2000 g ab und
setzt consume_by; 2 Essloeffel an /lots/{id}/consume buchen 20 g ab und
die Antwort traegt opened und lot.

Nicht kompiliert: kein Xcode auf dieser Maschine. Geprueft sind Klammer-
und Anfuehrungsbalance (und dabei EIN echter Fehler gefunden: ein
deutsches Anfuehrungszeichen mit geradem Schlusszeichen haette eine
Zeichenkette vorzeitig beendet), dass formatAmount global erreichbar
ist, und dass die benutzten Hilfsfunktionen so heissen. Der Build in
Xcode bleibt die Probe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 07:40:23 +02:00

921 lines
36 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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] = []
/// 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?
@State private var einzelstueckeFuer: 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) { einzelstueckeFuer = 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 mit eigener UID
// je Stück geht es gleich weiter zum Anlegen der Einzelstücke.
ProductFormView(prefillBarcode: nil, groupId: nil, initialType: fixedType) { created in
angelegt = created
showNew = false
Task { await load() }
}
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Abbrechen") { showNew = false }
}
}
}
}
.einzelstueckeNachAnlegen($einzelstueckeFuer)
.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
}
}
// 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 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
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)
}
// 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: $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
}
}
}