diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index 7285d03..70aec54 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -477,6 +477,39 @@ actor APIClient { return try await send(request, as: StockResponse.self) } + /// Alle Chargen mit Artikelangaben - fuer die uebergreifende Chargenliste. + func allLotRows() async throws -> [LotRow] { + try await send(try makeRequest("/lots/rows"), as: [LotRow].self) + } + + /// Eine Packung anbrechen: Sie wird als eigene, offene Charge gefuehrt und + /// bekommt ein "zu verbrauchen bis". Ohne Menge ist es eine Artikeleinheit. + @discardableResult + func openLot(id: Int, quantity: Double? = nil) async throws -> Lot { + var request = try makeRequest("/lots/\(id)/open", method: "POST") + try jsonBody(&request, ["quantity": quantity]) + return try await send(request, as: Lot.self) + } + + /// Teilmenge aus einer Charge verbrauchen. + /// + /// `autoOpen` bricht eine noch geschlossene Charge dabei an - die Rueckfrage + /// dazu stellt die Oberflaeche, nicht der Server. + @discardableResult + func consumeLot(id: Int, quantity: Double, unit: String, + note: String?, autoOpen: Bool) async throws -> ConsumeResult { + var request = try makeRequest("/lots/\(id)/consume", method: "POST") + try jsonBody(&request, ConsumeRequest(quantity: quantity, unit: unit, + note: note, autoOpen: autoOpen)) + return try await send(request, as: ConsumeResult.self) + } + + /// Die fuer DIESEN Artikel wirksamen Hausmasse samt Herkunft. + func productMeasures(productId: Int) async throws -> [MeasureAmount] { + try await send(try makeRequest("/products/\(productId)/measures"), + as: [MeasureAmount].self) + } + func updateLot(id: Int, _ payload: LotUpdateRequest) async throws -> Lot { var request = try makeRequest("/lots/\(id)", method: "PATCH") try jsonBody(&request, payload) diff --git a/ios/Sources/ListViews.swift b/ios/Sources/ListViews.swift index d2fa6a8..bbfeb73 100644 --- a/ios/Sources/ListViews.swift +++ b/ios/Sources/ListViews.swift @@ -649,3 +649,272 @@ struct ProductRow: View { 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 + } + } +} diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index ac60058..c3fdff2 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -344,6 +344,14 @@ struct Lot: Codable, Identifiable, Hashable { let bestBefore: String? let bestBeforePrecision: String? let locationId: String? + /// Oeffnungstag. nil = die Packung ist noch zu. + let openedOn: String? + /// "Zu verbrauchen bis" - beim Anbrechen aus der Frist gerechnet. + let consumeBy: String? + /// Menge beim Oeffnen, damit "noch 400 von 1000 ml" anzeigbar ist. + let openedQuantity: Double? + + var istAngebrochen: Bool { openedOn != nil } enum CodingKeys: String, CodingKey { case id, quantity @@ -351,9 +359,117 @@ struct Lot: Codable, Identifiable, Hashable { case bestBefore = "best_before" case bestBeforePrecision = "best_before_precision" case locationId = "location_id" + case openedOn = "opened_on" + case consumeBy = "consume_by" + case openedQuantity = "opened_quantity" } } +/// Eine Charge mit den Artikelangaben dran - fuer die uebergreifende +/// Chargenliste, in der Chargen verschiedener Artikel nebeneinander stehen. +struct LotRow: Codable, Identifiable, Hashable { + let id: Int + let productId: Int + let productName: String + let productBrand: String? + let quantity: Double + let baseUnit: String + let packageSize: Double? + let packageLabel: String? + let unitName: String + let unitFactor: Double + let bestBefore: String? + let bestBeforePrecision: String? + let locationId: String? + let openedOn: String? + let consumeBy: String? + let openedQuantity: Double? + + var istAngebrochen: Bool { openedOn != nil } + + /// Basiseinheiten je Artikeleinheit: das Gebinde, sonst die Anzeigeeinheit. + var artikelFaktor: Double { + if let p = packageSize, p > 0 { return p } + return unitFactor > 0 ? unitFactor : 1 + } + + /// Wie die Menge dasteht. Bei einer offenen Packung zaehlt die Restmenge in + /// Basiseinheiten - "0,98 Packungen" sagt niemandem etwas, "980 g" schon. + var mengeText: String { + if istAngebrochen { return "\(formatAmount(quantity)) \(baseKurz)" } + let menge = quantity / artikelFaktor + let label = (packageSize ?? 0) > 0 ? (packageLabel ?? "Packung") + : (unitName.isEmpty ? baseKurz : unitName) + return "\(formatAmount(menge)) \(label)" + } + + var baseKurz: String { + switch baseUnit { + case "gram": return "g" + case "milliliter": return "ml" + default: return "Stück" + } + } + + /// Token fuer die Basiseinheit, wie es das Backend beim Buchen erwartet. + var baseToken: String { + switch baseUnit { + case "gram": return "g" + case "milliliter": return "ml" + default: return "piece" + } + } + + enum CodingKeys: String, CodingKey { + case id, quantity + case productId = "product_id" + case productName = "product_name" + case productBrand = "product_brand" + case baseUnit = "base_unit" + case packageSize = "package_size" + case packageLabel = "package_label" + case unitName = "unit_name" + case unitFactor = "unit_factor" + case bestBefore = "best_before" + case bestBeforePrecision = "best_before_precision" + case locationId = "location_id" + case openedOn = "opened_on" + case consumeBy = "consume_by" + case openedQuantity = "opened_quantity" + } +} + +/// Ein Hausmass, das fuer einen Artikel wirklich gilt - "1 EL = 10 g". +/// +/// Die Mengen haengen an der Kategorie (oder am Artikel), nicht am Mass: Ein +/// Essloeffel Mehl wiegt anders als einer Zucker. +struct MeasureAmount: Codable, Identifiable, Hashable { + let measureId: Int + let name: String + let short: String? + let amount: Double + let base: String + let herkunft: String + + var id: Int { measureId } + + /// Was beim Buchen als Einheit mitgeschickt wird. + var token: String { name } + + enum CodingKeys: String, CodingKey { + case name, short, amount, base, herkunft + case measureId = "measure_id" + } +} + +/// Antwort auf einen Teilverbrauch. +struct ConsumeResult: Codable { + /// Wurde die Charge dabei angebrochen? + let opened: Bool + /// Die offene Charge danach - nil, wenn sie dabei leer geworden ist. + let lot: Lot? +} + struct StorageLocation: Codable, Identifiable, Hashable { let id: String let name: String @@ -821,6 +937,22 @@ struct ProductUpdateRequest: Encodable { } } +/// Teilverbrauch aus einer Charge. +/// +/// `unit` nimmt dasselbe wie das Auslagern: die Basiseinheit, eine verwaltete +/// Einheit, "package" - und die Hausmasse der Kategorie ("EL", "Tasse"). +struct ConsumeRequest: Encodable { + var quantity: Double + var unit: String + var note: String? + var autoOpen: Bool + + enum CodingKeys: String, CodingKey { + case quantity, unit, note + case autoOpen = "auto_open" + } +} + struct LotUpdateRequest: Encodable { var quantity: Double? var bestBefore: String? diff --git a/ios/Sources/ProductDetailView.swift b/ios/Sources/ProductDetailView.swift index 616269b..2763a72 100644 --- a/ios/Sources/ProductDetailView.swift +++ b/ios/Sources/ProductDetailView.swift @@ -284,7 +284,15 @@ struct ProductDetailView: View { } label: { HStack { VStack(alignment: .leading, spacing: 2) { - Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))") + // Eine offene Packung sah hier wie jede + // andere Charge aus - bei zwei Zeilen mit + // gleicher Menge ist das nicht zu erraten. + if lot.istAngebrochen, let bis = lot.consumeBy { + Text("angebrochen · verbrauchen bis \(display.formatBestBefore(bis, precision: "day"))") + .foregroundStyle(.orange) + } else { + Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))") + } // Ohne den Lagerort sehen zwei Chargen mit // gleichem MHD identisch aus – er ist hier // oft das einzige Unterscheidungsmerkmal. diff --git a/ios/Sources/RootView.swift b/ios/Sources/RootView.swift index 2d06962..dab2179 100644 --- a/ios/Sources/RootView.swift +++ b/ios/Sources/RootView.swift @@ -220,6 +220,12 @@ struct ListsTabView: View { ActionTile(title: "Einzelstücke", subtitle: "Alle Exemplare – filterbar nach Lagerort", systemImage: "tag") } + NavigationLink { + ChargenListView() + } label: { + ActionTile(title: "Chargen", subtitle: "Anbrechen und Teilmengen verbrauchen", + systemImage: "shippingbox.and.arrow.backward") + } NavigationLink { ShoppingListView() } label: {