Files
Vorrania/ios/Sources/ProductDetailView.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

871 lines
38 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
/// Produkt ansehen und ändern, dazu die Chargen korrigieren oder löschen.
struct ProductDetailView: View {
let product: Product
@EnvironmentObject private var display: DisplaySettings
@Environment(\.dismiss) private var dismiss
@State private var current: Product
@State private var showLeaveConfirm = false
@State private var lots: [Lot] = []
@State private var busy = false
@State private var error: String?
@State private var status: String?
// Bearbeitbare Felder
@State private var name = ""
@State private var brand = ""
@State private var packageSize = ""
@State private var packageLabel = ""
@State private var datePrecision = "day"
// Zweiteinheit („3 Stück ≙ 250 g") – Bruecke zur anderen Art.
@State private var zweitAnzahl = ""
@State private var zweitMenge = ""
@State private var zweitBasis = ""
@State private var groupId: Int?
@State private var categoryId: Int?
// Verwaltungsart eines Gegenstands: count | individual | bulk – umstellbar.
@State private var objMode = "count"
@State private var groups: [GroupItem] = []
@State private var categories: [CategoryItem] = []
// Gegenstände (Non-Food): Bezugsquelle, Link, Lagerorte und eigene Felder.
@State private var shopId: Int?
@State private var productUrl = ""
// Gesamt-Mindestbestand (nur Gegenstände; Menge in Stück/Artikeleinheit).
@State private var shops: [ShopItem] = []
@State private var locations: [StorageLocation] = []
@State private var fieldDefs: [FieldDefinition] = []
@State private var fieldValues: [String: String] = [:]
@State private var editLot: Lot?
// Mengen-Bestand (Menge je Lagerort): Sheet + Entnahme-Statistik werden hier
// gehalten und auf Form-Ebene präsentiert, damit das Sheet stabil aufgeht.
@State private var objectSheet: ObjectStockSheet?
@State private var objectRemovals: RemovalSummary?
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
init(product: Product) {
self.product = product
_current = State(initialValue: product)
}
/// Ungespeicherte Änderungen an den Artikel-Stammdaten? (Foto und Lagerort-
/// Bedarf speichern sofort und zählen hier nicht.)
private var isDirty: Bool {
if name != current.name { return true }
if brand != (current.brand ?? "") { return true }
if categoryId != current.categoryId { return true }
// Gruppe gibt es für alles außer Einzelstücke.
if !current.isIndividual, groupId != current.groupId { return true }
// Packung/MHD nur bei Lebensmitteln und Verbrauchsgegenständen.
if current.foodLike {
if packageSize != (current.packageSize.map { formatAmount($0) } ?? "") { return true }
if packageLabel != (current.packageLabel ?? "") { return true }
if datePrecision != (current.datePrecision == "month" ? "month" : "day") { return true }
if zweitBasis != (current.secondaryBase ?? "") { return true }
if zweitAnzahl != (current.secondaryCount.map { formatAmount($0) } ?? "") { return true }
if zweitMenge != (current.secondaryAmount.map { formatAmount($0) } ?? "") { return true }
}
if current.isObject {
if objMode != currentObjMode { return true }
if !current.isIndividual && !current.isBulk, shopId != current.shopId { return true }
if !current.isBulk, productUrl != (current.productUrl ?? "") { return true }
if fieldValues != Self.stringValues(current.fieldValues) { return true }
}
return false
}
private func attemptLeave() {
if isDirty { showLeaveConfirm = true } else { dismiss() }
}
var body: some View {
Form {
if let status {
Section { Text(status).foregroundStyle(.green).font(.callout) }
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
ProductPhotoSection(productId: current.id)
if current.isObject && current.isIndividual {
ProductItemsSection(product: current, onChanged: { await reload() },
onFinish: { dismiss() })
} else if current.isObject && !current.isBulk {
// Menge je Lagerort (Verbrauchsgegenstände laufen als Charge, s.u.).
ObjectStockSection(product: current, locations: locations, lots: lots,
removals: objectRemovals, sheet: $objectSheet)
} else {
// Lebensmittel und Verbrauchsgegenstände: Vorrat aus den Chargen.
Section("Bestand") {
// Mit Zweiteinheit beide Lesarten: „6 Stück · 500 g".
LabeledContent("Vorrat", value: {
let haupt = "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)"
guard let zweit = current.stockInZweit else { return haupt }
return "\(haupt) · \(formatAmount(zweit)) \(current.zweitKurz)"
}())
if current.expiredCount > 0 {
LabeledContent("Abgelaufen", value: "\(current.expiredCount)")
.foregroundStyle(.red)
}
}
}
// Beschriftungen links, Werte rechts: Ohne sie las man nur noch
// "Bratbutter" / "M-Classic" / "450" ohne jeden Zusammenhang.
Section("Artikel") {
LabeledField(label: "Name", text: $name)
LabeledField(label: "Marke", text: $brand)
}
// Alles zur Einheit steht zusammen und liest sich als Satz: das
// Gebinde zuerst, weil die Zeile darunter es beim Namen nennt.
if current.foodLike {
Section("Einheit und Menge") {
Picker("Gebinde", selection: $packageLabel) {
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
}
QuantityField(label: "1 \(gebindeName) sind", text: $packageSize,
suffix: display.baseUnitLabel(current.baseUnit))
Picker("MHD-Angabe", selection: $datePrecision) {
Text("Tagesdatum").tag("day")
Text("nur Monat/Jahr").tag("month")
}
}
// Umrechnung in die ANDERE Art: „3 Stück wiegen 250 g". Damit
// zaehlt der Artikel auch in Gruppen, die in dieser Einheit
// rechnen. Das Gebinde bleibt davon unberuehrt.
Section {
Picker("Umrechnen in", selection: Binding(
get: { zweitBasis },
set: { waehleZweitart($0) }
)) {
Text("– keine –").tag("")
ForEach(zweitBasisOptionen, id: \.self) { basis in
Text(display.baseUnitLabel(basis)).tag(basis)
}
}
if !zweitBasis.isEmpty {
QuantityField(label: "Menge", text: $zweitAnzahl,
suffix: display.baseUnitLabel(current.baseUnit))
QuantityField(label: zweitVerb, text: $zweitMenge,
suffix: display.baseUnitLabel(zweitBasis))
}
} header: {
Text("\(zweitTitel) (optional)")
} footer: {
Text(zweitHinweis)
}
}
Section {
CategoryPicker(categories: categories, selection: $categoryId,
tracking: current.tracking)
} footer: {
Text(current.isObject
? "Nur Gegenstands-Kategorien. Bestimmt die eigenen Felder."
: "Nur Lebensmittel-Kategorien.")
}
// Verwaltungsart eines Gegenstands umstellen (z.B. Menge je Lagerort →
// Einzelstücke). Wirkt nach dem Speichern; der Bestand wird nicht
// automatisch umgerechnet.
if current.isObject {
Section {
Picker("Verwaltung", selection: $objMode) {
Text("Menge je Lagerort").tag("count")
Text("Einzelstücke").tag("individual")
Text("Verbrauchsgegenstand").tag("bulk")
}
} footer: {
Text(objMode == currentObjMode
? "Wie der Bestand geführt wird."
: "Wird beim Speichern umgestellt – vorhandener Bestand wird nicht umgerechnet.")
}
}
// Bezugsquelle/Link nur für Menge je Lagerort und Einzelstücke –
// Verbrauchsgegenstände laufen wie Lebensmittel und lassen das weg.
if current.isObject && !current.isBulk {
// „Gekauft bei" gehört bei Einzelstücken zum einzelnen Stück
// (jedes kann woanders gekauft sein), nicht ans Modell – wie im
// Web wird der Shop hier dann ausgeblendet. Der Produktlink
// beschreibt das Produkt allgemein und bleibt.
Section(current.isIndividual ? "Produktlink" : "Gekauft bei") {
if !current.isIndividual {
Picker("Shop", selection: $shopId) {
Text("– unbekannt / mehrere –").tag(Int?.none)
ForEach(shops) { shop in Text(shop.name).tag(Int?.some(shop.id)) }
}
}
LabeledField(label: "Produktlink", text: $productUrl)
if !productUrl.isEmpty, let url = URL(string: productUrl) {
Link("Im Onlineshop öffnen", destination: url)
}
}
}
// Gruppe für alles außer Einzelstücke (Lebensmittel, Verbrauchs-
// gegenstand, Menge je Lagerort).
if !current.isIndividual {
Section {
Picker("Gruppe", selection: $groupId) {
Text("– keine –").tag(Int?.none)
// Als Baum, damit sichtbar ist, was unter was haengt.
ForEach(gruppenOptionen(groups)) { eintrag in
Text(eintrag.label).tag(Int?.some(eintrag.gruppe.id))
}
}
} footer: {
Text("Gruppe: zählt Bestände mehrerer Artikel zusammen. Der EAN-Code "
+ "wandert bei einem Wechsel mit.")
}
}
// Eigene Felder für alle Gegenstände mit Kategorie-Feldern.
if current.isObject && !fieldDefs.isEmpty {
Section("Eigene Felder") {
ForEach(fieldDefs) { def in
ObjectFieldRow(def: def, value: fieldBinding(def))
}
}
}
// Mindestbestand bei Lebensmitteln UND Verbrauchsgegenständen (Charge).
// Menge je Lagerort und Einzelstücke haben keinen Mindestbestand.
//
// Es gibt kein separates „Gesamt"-Feld mehr: der Bedarf hängt immer
// an einem Ort, und „Überall" ist einer davon (der oberste).
if current.foodLike {
Section {
NavigationLink {
ProductLocationMinView(product: current) { await reload() }
} label: {
let zeilen = current.locationMinStocks ?? []
let ueberall = zeilen.first { $0.locationId == nil }
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Mindestbestand")
Text(minStockUntertitel(zeilen: zeilen, ueberall: ueberall))
.font(.caption).foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "mappin.and.ellipse")
}
}
} footer: {
Text("„Überall“ heißt: egal wo, Hauptsache im Haus – Käufe für einen "
+ "Lagerort decken das mit ab.")
}
}
Section("Erkennung") {
LabeledContent("Barcode", value: current.barcode ?? "–")
if !current.barcodes.isEmpty {
LabeledContent("Weitere Codes", value: "\(current.barcodes.count)")
}
}
Section {
Button(busy ? "Speichern…" : "Änderungen speichern") { Task { await save() } }
.disabled(busy || name.isEmpty)
}
if current.foodLike {
Section("Chargen") {
ForEach(lots) { lot in
Button {
editLot = lot
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
// 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.
Text("\(formatAmount(lot.quantity / current.articleUnitFactor)) \(current.articleUnitLabel) · \(lotLocationLabel(lot))")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
}
}
.foregroundStyle(.primary)
}
.onDelete { indexSet in
Task { await deleteLots(at: indexSet) }
}
if lots.isEmpty {
Text("Keine Chargen im Bestand.").foregroundStyle(.secondary)
}
}
}
Section {
// Derselbe Verlauf wie im Listen-Tab, nur auf diesen Artikel
// gefiltert.
NavigationLink { HistoryView(productId: current.id) } label: {
Label("Verlauf dieses Artikels", systemImage: "clock.arrow.circlepath")
}
}
}
.navigationTitle(current.name)
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button { attemptLeave() } label: { Label("Zurück", systemImage: "chevron.left") }
}
}
.confirmationDialog("Ungespeicherte Änderungen", isPresented: $showLeaveConfirm,
titleVisibility: .visible) {
Button("Speichern") { Task { await save(); dismiss() } }
Button("Verwerfen", role: .destructive) { dismiss() }
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Änderungen an Name/Marke/Kategorie sind noch nicht gespeichert. "
+ "Foto und Lagerort-Bedarf werden sofort gespeichert.")
}
.sheet(item: $editLot) { lot in
NavigationStack {
LotEditView(lot: lot, product: current, locations: locations) {
Task { await reload() }
}
}
}
// Mengen-Sheets auf der stabilen Form-Ebene (nicht im Abschnitt), sonst
// schloss sich das Fenster beim ersten Öffnen sofort wieder.
.sheet(item: $objectSheet) { welche in
NavigationStack {
switch welche {
case .add:
ObjectAddSheet(product: current, locations: locations,
einheit: current.unitName.isEmpty ? "Stück" : current.unitName,
perform: { await reload() })
case .relocate(let from):
ObjectRelocateSheet(product: current, locations: locations, initialFrom: from,
perform: { await reload() })
case .remove(let loc):
ObjectRemoveSheet(product: current, locations: locations,
einheit: current.unitName.isEmpty ? "Stück" : current.unitName,
initialLoc: loc, perform: { await reload() })
}
}
}
.task {
fillForm()
await reload()
}
.onChange(of: categoryId) { newId in
// Bei Wechsel auf eine Gegenstands-Kategorie deren Felder nachladen.
Task {
if let cid = newId, categories.first(where: { $0.id == cid })?.isObject == true {
fieldDefs = (try? await APIClient.shared.categoryFields(categoryId: cid)) ?? []
} else {
fieldDefs = []
}
}
}
}
/// Kurzfassung der hinterlegten Mindestbestände für die Übersichtszeile.
private func minStockUntertitel(zeilen: [LocationMinStock], ueberall: LocationMinStock?) -> String {
if zeilen.isEmpty { return "keiner hinterlegt" }
let faktor = max(current.unitFactor, 1)
var teile: [String] = []
if let u = ueberall {
teile.append("Überall \(formatAmount(u.minStock / faktor)) \(current.unitName)")
}
let orte = zeilen.count - (ueberall == nil ? 0 : 1)
if orte > 0 { teile.append("\(orte) \(orte == 1 ? "Lagerort" : "Lagerorte")") }
return teile.joined(separator: " · ")
}
/// Basiseinheiten der ANDEREN Arten – eine Bruecke auf die eigene Art
/// haette nichts umzurechnen.
private var zweitBasisOptionen: [String] {
["piece", "gram", "milliliter"].filter { $0 != current.baseUnit }
}
/// Die Umrechnung heisst nach dem, was sie liefert – „Zweiteinheit" musste
/// man erst uebersetzen. Ohne gewaehlte Zielart ein neutraler Titel.
private var zweitTitel: String {
switch zweitBasis {
case "gram": return "Gewicht"
case "milliliter": return "Volumen"
case "piece": return "Stückzahl"
default: return "Umrechnung"
}
}
/// Verb passend zur Zielart und zur Anzahl: „1 Stück wiegt" / „3 wiegen".
private var zweitVerb: String {
let eins = Double(zweitAnzahl.replacingOccurrences(of: ",", with: ".")) == 1
switch zweitBasis {
case "gram": return eins ? "wiegt" : "wiegen"
case "milliliter": return eins ? "fasst" : "fassen"
case "piece": return eins ? "ist" : "sind"
default: return "sind"
}
}
/// Das Gebinde beim Namen nennen: „1 Packung sind …". Ohne Auswahl der
/// Standard, sonst stuende dort „1 sind 3 Stück".
private var gebindeName: String {
packageLabel.trimmingCharacters(in: .whitespaces).isEmpty ? "Packung" : packageLabel
}
/// Zielart waehlen und die linke Zahl EINMALIG mit der Packungsgroesse
/// vorbelegen – im Normalfall ist sie genau das. Bewusst keine dauerhafte
/// Kopplung: wer die Packung spaeter aendert, hat dieselbe Ware.
private func waehleZweitart(_ basis: String) {
zweitBasis = basis
if !basis.isEmpty, zweitAnzahl.isEmpty {
zweitAnzahl = packageSize.isEmpty ? "1" : packageSize
}
}
/// Kontrolle unter der Eingabe: „1 Stück ≈ 83,33 g".
private var zweitHinweis: String {
let n = Double(zweitAnzahl.replacingOccurrences(of: ",", with: ".")) ?? 0
let m = Double(zweitMenge.replacingOccurrences(of: ",", with: ".")) ?? 0
guard !zweitBasis.isEmpty, n > 0, m > 0 else {
return "Zusätzliche Lesart desselben Artikels – damit zählt er auch in "
+ "Gruppen der anderen Art und lässt sich darin ein- und auslagern."
}
return "1 \(display.baseUnitLabel(current.baseUnit)) ≈ \(formatAmount(m / n)) "
+ "\(display.baseUnitLabel(zweitBasis)). Damit zählt der Artikel auch in "
+ "Gruppen, die in dieser Einheit rechnen. Bestände bleiben beim Ändern "
+ "unverändert – nur ihre Umrechnung verschiebt sich."
}
/// Aktuelle Verwaltungsart des geladenen Artikels.
private var currentObjMode: String {
current.isIndividual ? "individual" : (current.isBulk ? "bulk" : "count")
}
private func fillForm() {
name = current.name
brand = current.brand ?? ""
packageSize = current.packageSize.map { formatAmount($0) } ?? ""
packageLabel = current.packageLabel ?? ""
datePrecision = current.datePrecision == "month" ? "month" : "day"
zweitBasis = current.secondaryBase ?? ""
zweitAnzahl = current.secondaryCount.map { formatAmount($0) } ?? ""
zweitMenge = current.secondaryAmount.map { formatAmount($0) } ?? ""
groupId = current.groupId
categoryId = current.categoryId
shopId = current.shopId
productUrl = current.productUrl ?? ""
objMode = currentObjMode
fieldValues = Self.stringValues(current.fieldValues)
}
private static func stringValues(_ dict: [String: String?]?) -> [String: String] {
guard let dict else { return [:] }
var out: [String: String] = [:]
for (schluessel, wert) in dict { if let wert { out[schluessel] = wert } }
return out
}
private func fieldBinding(_ def: FieldDefinition) -> Binding<String> {
let key = String(def.id)
return Binding(get: { fieldValues[key] ?? "" }, set: { fieldValues[key] = $0 })
}
/// Lagerort einer Charge als Pfad („Keller → Regal 2"). Chargen ohne Ort –
/// und solche, deren Ort (noch) nicht geladen ist – bleiben „ohne Ort".
private func lotLocationLabel(_ lot: Lot) -> String {
guard let id = lot.locationId,
let ort = locations.first(where: { $0.id == id }) else { return "ohne Ort" }
return ort.path(in: locations)
}
private func reload() async {
groups = (try? await APIClient.shared.groups()) ?? []
categories = (try? await APIClient.shared.categories()) ?? []
lots = (try? await APIClient.shared.lots(productId: current.id)) ?? []
shops = (try? await APIClient.shared.shops()) ?? []
locations = (try? await APIClient.shared.locations()) ?? []
if let frisch = try? await APIClient.shared.product(id: current.id) {
current = frisch
}
if current.isObject, let cid = current.categoryId {
fieldDefs = (try? await APIClient.shared.categoryFields(categoryId: cid)) ?? []
} else {
fieldDefs = []
}
// Entnahme-Statistik nur für „Menge je Lagerort"-Gegenstände.
if current.isObject && !current.isBulk && !current.isIndividual {
objectRemovals = try? await APIClient.shared.productRemovals(id: current.id)
} else {
objectRemovals = nil
}
}
/// Verwaltungsart (individual/bulk) mitschicken, wenn sie umgestellt wurde.
private func applyModeChange(_ req: inout ProductUpdateRequest) {
guard current.isObject, objMode != currentObjMode else { return }
req.individual = objMode == "individual"
req.bulk = objMode == "bulk"
req.sendMode = true
}
private func save() async {
error = nil
status = nil
busy = true
defer { busy = false }
do {
if current.foodLike {
// Lebensmittel ODER Verbrauchsgegenstand: als Charge mit Einheit/
// Packung/MHD/Gruppe. Verbrauchsgegenstände (isObject) tragen
// zusätzlich eigene Felder und einen Mindestbestand.
var req = ProductUpdateRequest(
name: name,
brand: brand.isEmpty ? nil : brand,
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
packageLabel: packageLabel.isEmpty ? nil : packageLabel,
datePrecision: datePrecision,
groupId: groupId,
categoryId: categoryId
)
// Der Mindestbestand wird in ProductLocationMinView gepflegt und
// hier bewusst NICHT mitgeschickt – sonst überschriebe das
// Stammdaten-Speichern die dort gesetzte „Überall"-Zeile.
//
// Zweiteinheit nur vollstaendig; leere oder halbe Angabe loescht
// die Bruecke (secondary_base null raeumt im Backend mit auf).
let zn = Double(zweitAnzahl.replacingOccurrences(of: ",", with: "."))
let zm = Double(zweitMenge.replacingOccurrences(of: ",", with: "."))
req.sendSecondary = true
if !zweitBasis.isEmpty, let zn, let zm, zn > 0, zm > 0 {
req.secondaryBase = zweitBasis
req.secondaryCount = zn
req.secondaryAmount = zm
}
if current.isObject {
var fv: [String: String?] = [:]
for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" }
req.fieldValues = fv
}
applyModeChange(&req)
current = try await APIClient.shared.updateProduct(id: current.id, req)
if current.isObject { fieldValues = Self.stringValues(current.fieldValues) }
} else {
// Gegenstand: Menge je Lagerort oder Einzelstücke. Kein
// Mindestbestand (Alt-Werte werden geleert); Menge-Gegenstände
// dürfen weiter in eine Gruppe.
var fv: [String: String?] = [:]
for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" }
var req = ProductUpdateRequest(
name: name,
brand: brand.isEmpty ? nil : brand,
packageSize: nil, packageLabel: nil, datePrecision: nil,
groupId: current.isIndividual ? nil : groupId, categoryId: categoryId,
// Einzelstücke tragen den Shop je Stück – nichts ans Modell.
shopId: current.isIndividual ? nil : shopId,
productUrl: productUrl.isEmpty ? nil : productUrl,
fieldValues: fv,
minStock: nil,
minStockInPackages: false,
minStockUnitId: nil,
sendMinStock: true
)
applyModeChange(&req)
current = try await APIClient.shared.updateProduct(id: current.id, req)
fieldValues = Self.stringValues(current.fieldValues)
}
status = "Gespeichert."
} catch {
self.error = error.localizedDescription
}
}
private func deleteLots(at indexSet: IndexSet) async {
error = nil
for index in indexSet {
do {
try await APIClient.shared.deleteLot(id: lots[index].id)
} catch {
self.error = error.localizedDescription
}
}
await reload()
}
}
/// Lädt ein Produkt anhand seiner ID nach und zeigt dann die Detailansicht –
/// z.B. beim Antippen einer Bewegung im Verlauf oder auf der Startseite.
struct ProductByIdView: View {
let productId: Int
@State private var product: Product?
@State private var error: String?
var body: some View {
Group {
if let product {
ProductDetailView(product: product)
} else if let error {
VStack(spacing: 12) {
Text(error).foregroundStyle(.red).font(.callout)
.multilineTextAlignment(.center)
Button("Erneut versuchen") { Task { await load() } }
}
.padding()
} else {
ProgressView()
}
}
.task { if product == nil { await load() } }
}
private func load() async {
do {
product = try await APIClient.shared.product(id: productId)
error = nil
} catch {
self.error = error.localizedDescription
}
}
}
/// Menge, MHD und Lagerort einer Charge korrigieren.
struct LotEditView: View {
let lot: Lot
let product: Product
let locations: [StorageLocation]
var onSaved: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var quantity = ""
@State private var hasDate = false
@State private var bestBefore = Date()
@State private var precision = "day"
@State private var locationId: String?
@State private var locScanShown = false
@State private var busy = false
@State private var error: String?
var body: some View {
Form {
Section("Menge") {
QuantityField(label: "Menge", text: $quantity,
suffix: product.articleUnitLabel)
}
Section("MHD") {
Toggle("MHD angeben", isOn: $hasDate)
if hasDate {
Picker("Angabe", selection: $precision) {
Text("Tagesdatum").tag("day")
Text("nur Monat/Jahr").tag("month")
}
if precision == "month" {
MonthYearPicker(date: $bestBefore)
} else {
DatePicker("MHD", selection: $bestBefore, displayedComponents: .date)
}
}
}
if !locations.isEmpty {
Section("Lagerort") {
Picker("Lagerort", selection: $locationId) {
Text("– ohne –").tag(String?.none)
ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) }
}
// Ort per QR am Regal/Fach setzen, statt in der Liste zu suchen.
Button { locScanShown = true } label: {
Label("Lagerort scannen", systemImage: "qrcode.viewfinder")
}
}
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
Section {
Button(busy ? "Speichern…" : "Speichern") { Task { await save() } }
.disabled(busy)
}
}
.navigationTitle("Charge ändern")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
}
.fullScreenCover(isPresented: $locScanShown) {
LocationScannerView(locations: locations) { ort in locationId = ort.id }
.ignoresSafeArea()
}
.onAppear(perform: fill)
}
private func fill() {
quantity = formatAmount(lot.quantity / product.articleUnitFactor)
locationId = lot.locationId
precision = lot.bestBeforePrecision == "month" ? "month" : "day"
if let raw = lot.bestBefore {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
if let datum = formatter.date(from: raw) {
bestBefore = datum
hasDate = true
}
}
}
private func save() async {
error = nil
guard let menge = Double(quantity.replacingOccurrences(of: ",", with: ".")), menge > 0 else {
error = "Bitte eine Menge größer 0 angeben."
return
}
busy = true
defer { busy = false }
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
let datum = precision == "month" ? bestBefore.startOfMonth : bestBefore
do {
// Das Backend rechnet Chargenmengen in Basiseinheiten.
_ = try await APIClient.shared.updateLot(
id: lot.id,
LotUpdateRequest(
quantity: menge * product.articleUnitFactor,
bestBefore: hasDate ? formatter.string(from: datum) : nil,
bestBeforePrecision: precision,
locationId: locationId
)
)
onSaved()
dismiss()
} catch {
self.error = error.localizedDescription
}
}
}
// MARK: - Mindestbestand je Lagerort
/// Bedarf eines Produkts je Lagerort bearbeiten (zusätzlich zum globalen
/// Mindestbestand). Menge in Artikeleinheiten.
struct ProductLocationMinView: View {
let product: Product
var onChanged: (() async -> Void)? = nil
@Environment(\.dismiss) private var dismiss
private struct MinRow: Identifiable {
let id = UUID()
var locationId: String?
var amount: String
}
@State private var locations: [StorageLocation] = []
@State private var rows: [MinRow] = []
@State private var busy = false
@State private var error: String?
// Gespeichert wird in Basiseinheiten, erfasst in der Anzeigeeinheit (g/ml/Stück).
private var dispFaktor: Double { product.unitFactor == 0 ? 1 : product.unitFactor }
var body: some View {
Form {
Section {
Text("Was soll wo immer da sein? „Überall“ heißt: egal wo, Hauptsache "
+ "im Haus – Käufe für einen Lagerort decken das mit ab. "
+ "Menge in \(product.unitName).")
.font(.caption).foregroundStyle(.secondary)
}
ForEach($rows) { $row in
HStack {
Picker("Ort", selection: $row.locationId) {
Text(UEBERALL_NAME).tag(String?.some(UEBERALL_ID))
ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) }
}
TextField("Menge", text: $row.amount)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 70)
Button(role: .destructive) {
rows.removeAll { $0.id == row.id }
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
}
}
Button {
rows.append(MinRow(locationId: UEBERALL_ID, amount: ""))
} label: {
Label("Mindestbestand hinzufügen", systemImage: "plus")
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
}
.navigationTitle("Mindestbestand")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(busy ? "Speichern…" : "Speichern") { Task { await save() } }
.disabled(busy)
}
}
.task { await load() }
}
private func load() async {
locations = (try? await APIClient.shared.locations()) ?? []
// „Überall" ist der Ort nil – im Picker braucht es einen Platzhalter.
rows = (product.locationMinStocks ?? []).map {
MinRow(locationId: $0.locationId ?? UEBERALL_ID,
amount: formatAmount($0.minStock / dispFaktor))
}
}
private func save() async {
busy = true
defer { busy = false }
var list: [LocationMinStockIn] = []
var gesehen: Set<String> = []
for row in rows {
guard let loc = row.locationId, !gesehen.contains(loc) else { continue }
let wert = Double(row.amount.replacingOccurrences(of: ",", with: ".")) ?? 0
if wert > 0 {
gesehen.insert(loc)
// Eingabe in Anzeigeeinheit → Basiseinheiten (so gespeichert).
list.append(LocationMinStockIn(
locationId: loc == UEBERALL_ID ? nil : loc,
minStock: wert * dispFaktor))
}
}
do {
_ = try await APIClient.shared.setProductLocationMinStock(id: product.id, list)
await onChanged?()
dismiss()
} catch {
self.error = error.localizedDescription
}
}
}