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>
This commit is contained in:
Scarriffle
2026-10-03 07:40:23 +02:00
co-authored by Claude Opus 5
parent f04d80a330
commit c860c82912
5 changed files with 449 additions and 1 deletions
+269
View File
@@ -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
}
}
}