Gegenstands-Verwaltung (Non-Food) neben Lebensmitteln
Die Kategorie bestimmt die Verwaltungsart (food/object). Gegenstaende: Menge je Lagerort statt Chargen/MHD, Umlagern (ohne Grund) und Entfernen mit Pflicht-Grund samt Entnahme-Statistik. Beliebig viele eigene Felder je Kategorie (vererbt an Unterkategorien), "gekauft bei" ueber eine verwaltbare Shop-Liste und ein Produktlink. Barcode-Lookup zusaetzlich ueber Open Products Facts. Der Lebensmittel-Teil (Chargen/MHD/FEFO) bleibt unveraendert. Umgesetzt in Backend (FastAPI, +Tests gruen), Web (React) und iOS (SwiftUI). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,14 @@ struct ProductDetailView: View {
|
||||
@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 = ""
|
||||
@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?
|
||||
|
||||
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
|
||||
@@ -41,12 +49,17 @@ struct ProductDetailView: View {
|
||||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||
}
|
||||
|
||||
Section("Bestand") {
|
||||
LabeledContent("Vorrat",
|
||||
value: "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)")
|
||||
if current.expiredCount > 0 {
|
||||
LabeledContent("Abgelaufen", value: "\(current.expiredCount)")
|
||||
.foregroundStyle(.red)
|
||||
if current.isObject {
|
||||
ObjectStockSection(product: current, locations: locations, lots: lots,
|
||||
onChanged: { await reload() })
|
||||
} else {
|
||||
Section("Bestand") {
|
||||
LabeledContent("Vorrat",
|
||||
value: "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)")
|
||||
if current.expiredCount > 0 {
|
||||
LabeledContent("Abgelaufen", value: "\(current.expiredCount)")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,33 +68,57 @@ struct ProductDetailView: View {
|
||||
Section("Artikel") {
|
||||
LabeledField(label: "Name", text: $name)
|
||||
LabeledField(label: "Marke", text: $brand)
|
||||
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")
|
||||
if !current.isObject {
|
||||
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)
|
||||
} footer: {
|
||||
Text("Kategorie: nur für den Überblick in der Artikelliste.")
|
||||
Text(current.isObject
|
||||
? "Kategorie bestimmt die Verwaltungsart (Gegenstand) und die eigenen Felder."
|
||||
: "Kategorie: nur für den Überblick in der Artikelliste.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Gruppe", selection: $groupId) {
|
||||
Text("– keine –").tag(Int?.none)
|
||||
ForEach(groups) { gruppe in
|
||||
Text(gruppe.name).tag(Int?.some(gruppe.id))
|
||||
if current.isObject {
|
||||
Section("Gekauft bei") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("Gruppe: zählt Bestände mehrerer Marken zusammen. Der EAN-Code "
|
||||
+ "wandert bei einem Wechsel mit.")
|
||||
if !fieldDefs.isEmpty {
|
||||
Section("Eigene Felder") {
|
||||
ForEach(fieldDefs) { def in
|
||||
ObjectFieldRow(def: def, value: fieldBinding(def))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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 Marken zusammen. Der EAN-Code "
|
||||
+ "wandert bei einem Wechsel mit.")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Erkennung") {
|
||||
@@ -96,28 +133,30 @@ struct ProductDetailView: View {
|
||||
.disabled(busy || name.isEmpty)
|
||||
}
|
||||
|
||||
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))")
|
||||
Text("\(formatAmount(lot.quantity / current.articleUnitFactor)) \(current.articleUnitLabel)")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
if !current.isObject {
|
||||
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))")
|
||||
Text("\(formatAmount(lot.quantity / current.articleUnitFactor)) \(current.articleUnitLabel)")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
|
||||
}
|
||||
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)
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
Task { await deleteLots(at: indexSet) }
|
||||
}
|
||||
if lots.isEmpty {
|
||||
Text("Keine Chargen im Bestand.").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +181,16 @@ struct ProductDetailView: View {
|
||||
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 = []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fillForm() {
|
||||
@@ -152,15 +201,37 @@ struct ProductDetailView: View {
|
||||
datePrecision = current.datePrecision == "month" ? "month" : "day"
|
||||
groupId = current.groupId
|
||||
categoryId = current.categoryId
|
||||
shopId = current.shopId
|
||||
productUrl = current.productUrl ?? ""
|
||||
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 })
|
||||
}
|
||||
|
||||
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 = []
|
||||
}
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
@@ -169,18 +240,37 @@ struct ProductDetailView: View {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
do {
|
||||
current = try await APIClient.shared.updateProduct(
|
||||
id: current.id,
|
||||
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
|
||||
if current.isObject {
|
||||
// Gegenstände: keine Lebensmittel-Felder, dafür Shop, Link und eigene Felder.
|
||||
var fv: [String: String?] = [:]
|
||||
for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" }
|
||||
current = try await APIClient.shared.updateProduct(
|
||||
id: current.id,
|
||||
ProductUpdateRequest(
|
||||
name: name,
|
||||
brand: brand.isEmpty ? nil : brand,
|
||||
packageSize: nil, packageLabel: nil, datePrecision: nil,
|
||||
groupId: nil, categoryId: categoryId,
|
||||
shopId: shopId,
|
||||
productUrl: productUrl.isEmpty ? nil : productUrl,
|
||||
fieldValues: fv
|
||||
)
|
||||
)
|
||||
)
|
||||
fieldValues = Self.stringValues(current.fieldValues)
|
||||
} else {
|
||||
current = try await APIClient.shared.updateProduct(
|
||||
id: current.id,
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
status = "Gespeichert."
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
|
||||
Reference in New Issue
Block a user