In der Chargenliste eines Artikels standen nur MHD und Menge. Zwei Chargen
mit gleichem MHD, die sich nur im Lagerort unterscheiden, sahen dadurch
identisch aus. Die Zeile nennt jetzt zusaetzlich den Lagerort-Pfad (bzw.
"ohne Ort").
Im Sheet "Charge aendern" kommt ein Lagerort-Picker samt QR-Scan dazu -
wie in der Weboberflaeche. Das Backend konnte das ueber PATCH /lots/{id}
schon; LotUpdateRequest sendet location_id jetzt immer mit (auch null),
damit sich ein gesetzter Ort wieder entfernen laesst.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
743 lines
31 KiB
Swift
743 lines
31 KiB
Swift
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"
|
||
@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 minStock = ""
|
||
@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 }
|
||
// Mindestbestand gibt es bei Lebensmitteln und Verbrauchsgegenständen.
|
||
if minStock != minStockFeld { 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") {
|
||
LabeledContent("Vorrat",
|
||
value: "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)")
|
||
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)
|
||
if current.foodLike {
|
||
QuantityField(label: "Packungsgröße", text: $packageSize,
|
||
suffix: display.baseUnitLabel(current.baseUnit))
|
||
Picker("Bezeichnung", selection: $packageLabel) {
|
||
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
|
||
}
|
||
Picker("MHD-Angabe", selection: $datePrecision) {
|
||
Text("Tagesdatum").tag("day")
|
||
Text("nur Monat/Jahr").tag("month")
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
ForEach(groups) { gruppe in
|
||
Text(gruppe.name).tag(Int?.some(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) –
|
||
// in der Anzeigeeinheit (z.B. Gramm/Milliliter), nicht in Packungen.
|
||
// Menge je Lagerort und Einzelstücke haben keinen Mindestbestand.
|
||
if current.foodLike {
|
||
Section("Mindestbestand") {
|
||
QuantityField(label: "Gesamt (\(current.unitName))", text: $minStock)
|
||
}
|
||
}
|
||
|
||
if current.foodLike {
|
||
Section {
|
||
NavigationLink {
|
||
ProductLocationMinView(product: current) { await reload() }
|
||
} label: {
|
||
let n = (current.locationMinStocks ?? []).count
|
||
Label(n > 0 ? "Mindestbestand je Lagerort (\(n))" : "Mindestbestand je Lagerort",
|
||
systemImage: "mappin.and.ellipse")
|
||
}
|
||
} footer: {
|
||
Text("Bedarf je Lagerort (z.B. Ferienhaus, Zuhause), zusätzlich zum Gesamt-Mindestbestand.")
|
||
}
|
||
}
|
||
|
||
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) {
|
||
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 = []
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Gesamt-Mindestbestand in der Anzeigeeinheit (Gramm/Milliliter/Stück) – aus
|
||
/// den Basiseinheiten umgerechnet, unabhängig davon, wie er erfasst wurde.
|
||
private var minStockFeld: String {
|
||
current.minStock.map { formatAmount($0 / max(current.unitFactor, 1)) } ?? ""
|
||
}
|
||
|
||
/// 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"
|
||
groupId = current.groupId
|
||
categoryId = current.categoryId
|
||
shopId = current.shopId
|
||
productUrl = current.productUrl ?? ""
|
||
minStock = minStockFeld
|
||
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 {
|
||
// Mindestbestand in der Anzeigeeinheit erfasst → Basiseinheiten.
|
||
let minBase = Double(minStock.replacingOccurrences(of: ",", with: "."))
|
||
.map { $0 * current.unitFactor }
|
||
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
|
||
)
|
||
// Mindestbestand für Lebensmittel UND Verbrauchsgegenstände.
|
||
req.minStock = minBase
|
||
req.minStockInPackages = false
|
||
req.minStockUnitId = nil
|
||
req.sendMinStock = true
|
||
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?
|
||
|
||
// Speicherung in Artikeleinheiten, Anzeige in der Anzeigeeinheit (Gramm/ml/Stück).
|
||
private var artToDisp: Double {
|
||
let disp = product.unitFactor == 0 ? 1 : product.unitFactor
|
||
return product.articleUnitFactor / disp
|
||
}
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
Text("Bedarf je Lagerort (z.B. Ferienhaus, Zuhause). Menge in \(product.unitName).")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
ForEach($rows) { $row in
|
||
HStack {
|
||
Picker("Lagerort", selection: $row.locationId) {
|
||
Text("– wählen –").tag(String?.none)
|
||
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: nil, amount: ""))
|
||
} label: {
|
||
Label("Lagerort hinzufügen", systemImage: "plus")
|
||
}
|
||
if let error {
|
||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||
}
|
||
}
|
||
.navigationTitle("Bedarf je Lagerort")
|
||
.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()) ?? []
|
||
rows = (product.locationMinStocks ?? []).map {
|
||
MinRow(locationId: $0.locationId, amount: formatAmount($0.minStock * artToDisp))
|
||
}
|
||
}
|
||
|
||
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 → Artikeleinheiten (so gespeichert).
|
||
list.append(LocationMinStockIn(locationId: loc, minStock: wert / artToDisp))
|
||
}
|
||
}
|
||
do {
|
||
_ = try await APIClient.shared.setProductLocationMinStock(id: product.id, list)
|
||
await onChanged?()
|
||
dismiss()
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
}
|