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:
Scarriffle
2026-07-25 15:33:56 +02:00
parent 580afcc133
commit 5b952524d7
29 changed files with 3116 additions and 115 deletions

View File

@@ -188,6 +188,43 @@ actor APIClient {
try check(response, data: data)
}
// MARK: - Gegenstaende (Non-Food)
func shops() async throws -> [ShopItem] {
try await send(try makeRequest("/shops"), as: [ShopItem].self)
}
/// Effektive (vererbte) Felder einer Kategorie fuer das Artikelformular.
func categoryFields(categoryId: Int) async throws -> [FieldDefinition] {
try await send(try makeRequest("/categories/\(categoryId)/fields"), as: [FieldDefinition].self)
}
/// Menge eines Gegenstands an einem Lagerort erhoehen.
func objectCheckIn(_ payload: ObjectCheckInRequest) async throws -> StockResponse {
var request = try makeRequest("/stock/checkin", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: StockResponse.self)
}
/// Menge von einem Lagerort zum anderen umbuchen (ohne Grund).
func relocate(_ payload: RelocateRequest) async throws -> StockResponse {
var request = try makeRequest("/stock/relocate", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: StockResponse.self)
}
/// Menge mit Pflicht-Grund aus dem Bestand entfernen.
func removeStock(_ payload: RemoveRequest) async throws -> StockResponse {
var request = try makeRequest("/stock/remove", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: StockResponse.self)
}
/// Entnahme-Statistik (Summe je Grund) und die juengsten Entnahmen.
func productRemovals(id: Int) async throws -> RemovalSummary {
try await send(try makeRequest("/products/\(id)/removals"), as: RemovalSummary.self)
}
// MARK: - Uebersicht und Verlauf
func dashboardStats() async throws -> DashboardStats {

View File

@@ -66,9 +66,17 @@ struct Product: Codable, Identifiable, Hashable {
/// Mindestbestand in der erfassten Einheit, fuer die Anzeige.
let minStockDisplay: Double?
let minStockUnitLabel: String?
/// Verwaltungsart aus der Kategorie: "food" (Chargen+MHD) oder "object" (Menge je Ort).
let tracking: String?
/// Nur fuer Gegenstaende: Bezugsquelle und Onlineshop-Link.
let shopId: Int?
let shopName: String?
let productUrl: String?
/// Selbst definierte Feldwerte: {feld_id (als Text): Wert}.
let fieldValues: [String: String?]?
enum CodingKeys: String, CodingKey {
case id, barcode, name, brand, stock, kind, barcodes
case id, barcode, name, brand, stock, kind, barcodes, tracking
case datePrecision = "date_precision"
case categoryId = "category_id"
case categoryName = "category_name"
@@ -83,8 +91,15 @@ struct Product: Codable, Identifiable, Hashable {
case expiredCount = "expired_count"
case unitName = "unit_name"
case unitFactor = "unit_factor"
case shopId = "shop_id"
case shopName = "shop_name"
case productUrl = "product_url"
case fieldValues = "field_values"
}
/// Gegenstand (Menge je Lagerort) statt Lebensmittel (Chargen/MHD)?
var isObject: Bool { tracking == "object" }
/// Bezeichnung der Artikeleinheit (Gebinde, sonst Produkteinheit).
var articleUnitLabel: String {
if let size = packageSize, size > 0 { return packageLabel ?? "Packung" }
@@ -353,6 +368,10 @@ struct ProductUpdateRequest: Encodable {
var datePrecision: String?
var groupId: Int?
var categoryId: Int?
// Nur fuer Gegenstaende (Standard nil = nicht mitschicken bei fieldValues).
var shopId: Int? = nil
var productUrl: String? = nil
var fieldValues: [String: String?]? = nil
enum CodingKeys: String, CodingKey {
case name, brand
@@ -361,6 +380,9 @@ struct ProductUpdateRequest: Encodable {
case datePrecision = "date_precision"
case groupId = "group_id"
case categoryId = "category_id"
case shopId = "shop_id"
case productUrl = "product_url"
case fieldValues = "field_values"
}
func encode(to encoder: Encoder) throws {
@@ -372,6 +394,10 @@ struct ProductUpdateRequest: Encodable {
try container.encode(datePrecision, forKey: .datePrecision)
try container.encode(groupId, forKey: .groupId)
try container.encode(categoryId, forKey: .categoryId)
try container.encode(shopId, forKey: .shopId)
try container.encode(productUrl, forKey: .productUrl)
// Feldwerte nur senden, wenn gesetzt sonst nichts an den Feldern ändern.
if let fieldValues { try container.encode(fieldValues, forKey: .fieldValues) }
}
}
@@ -399,16 +425,143 @@ struct GroupItem: Codable, Identifiable, Hashable {
let name: String
}
/// Kategorie: ordnet nur die Artikelliste, verschachtelbar (Suesswaren -> Schokolade).
/// Kategorie: ordnet die Artikelliste und bestimmt die Verwaltungsart
/// ("food"/"object"), verschachtelbar (Suesswaren -> Schokolade).
struct CategoryItem: Codable, Identifiable, Hashable {
let id: Int
let name: String
let parentId: Int?
let tracking: String?
enum CodingKeys: String, CodingKey {
case id, name
case id, name, tracking
case parentId = "parent_id"
}
var isObject: Bool { tracking == "object" }
}
/// Shop / Bezugsquelle fuer Gegenstaende ("gekauft bei").
struct ShopItem: Codable, Identifiable, Hashable {
let id: Int
let name: String
let website: String?
let productCount: Int?
enum CodingKeys: String, CodingKey {
case id, name, website
case productCount = "product_count"
}
}
/// Selbst definiertes Feld einer Kategorie (inkl. der geerbten).
struct FieldDefinition: Codable, Identifiable, Hashable {
let id: Int
let categoryId: Int
let label: String
let key: String
let fieldType: String
let unit: String?
let options: [String]
let required: Bool
let position: Int
let isBuiltin: Bool
let inherited: Bool
enum CodingKeys: String, CodingKey {
case id, label, key, unit, options, required, position, inherited
case categoryId = "category_id"
case fieldType = "field_type"
case isBuiltin = "is_builtin"
}
}
// MARK: - Gegenstaende: Bestandsbuchungen
struct ObjectCheckInRequest: Codable {
let productId: Int
let quantity: Double
let unit: String
let locationId: Int?
enum CodingKeys: String, CodingKey {
case quantity, unit
case productId = "product_id"
case locationId = "location_id"
}
}
struct RelocateRequest: Codable {
let productId: Int
let quantity: Double
let fromLocationId: Int?
let toLocationId: Int?
enum CodingKeys: String, CodingKey {
case quantity
case productId = "product_id"
case fromLocationId = "from_location_id"
case toLocationId = "to_location_id"
}
}
struct RemoveRequest: Codable {
let productId: Int
let quantity: Double
let locationId: Int?
let reason: String
let note: String?
enum CodingKeys: String, CodingKey {
case quantity, reason, note
case productId = "product_id"
case locationId = "location_id"
}
}
struct RemovalStat: Codable, Identifiable, Hashable {
let reason: String
let quantity: Double
let count: Int
var id: String { reason }
}
struct RemovalHistoryItem: Codable, Identifiable, Hashable {
let reason: String
let quantity: Double
let locationId: Int?
let locationName: String?
let note: String?
let username: String?
let createdAt: String
var id: String { "\(reason)-\(createdAt)-\(quantity)" }
enum CodingKeys: String, CodingKey {
case reason, quantity, note, username
case locationId = "location_id"
case locationName = "location_name"
case createdAt = "created_at"
}
}
struct RemovalSummary: Codable {
let stats: [RemovalStat]
let history: [RemovalHistoryItem]
}
/// Anzeige-Bezeichnungen der Entnahmegruende.
enum RemovalReasons {
static let all: [(value: String, label: String)] = [
("broken", "kaputt"),
("lost", "verloren"),
("given_away", "verschenkt"),
("sold", "verkauft"),
("used_up", "aufgebraucht"),
("other", "sonstiges"),
]
static func label(_ value: String) -> String {
all.first { $0.value == value }?.label ?? value
}
}
// MARK: - Uebersicht und Verlauf

View File

@@ -0,0 +1,327 @@
import SwiftUI
/// Bestand eines Gegenstands je Lagerort das Gegenstück zur Chargen-Liste der
/// Lebensmittel. Erlaubt Hinzufügen, Umlagern (ohne Grund) und Entfernen (mit
/// Pflicht-Grund) und zeigt eine kleine Entnahme-Statistik. Wird als Abschnitt
/// in die Produkt-Detailansicht eingebettet.
struct ObjectStockSection: View {
let product: Product
let locations: [StorageLocation]
let lots: [Lot]
var onChanged: () async -> Void
@State private var removals: RemovalSummary?
@State private var sheet: StockSheet?
enum StockSheet: Identifiable {
case add
case relocate(from: Int?)
case remove(loc: Int?)
var id: String {
switch self {
case .add: return "add"
case .relocate(let f): return "relocate-\(f.map(String.init) ?? "none")"
case .remove(let l): return "remove-\(l.map(String.init) ?? "none")"
}
}
}
private var einheit: String { product.unitName.isEmpty ? "Stück" : product.unitName }
private func ortName(_ id: Int?) -> String {
guard let id else { return "Ohne Lagerort" }
return locations.first { $0.id == id }?.name ?? "Ort \(id)"
}
var body: some View {
Group {
Section("Bestand je Lagerort") {
ForEach(lots) { lot in
HStack {
Text(ortName(lot.locationId))
Spacer()
Text("\(formatAmount(lot.quantity)) \(einheit)")
.foregroundStyle(.secondary)
}
}
if lots.isEmpty {
Text("Noch kein Bestand.").foregroundStyle(.secondary)
}
HStack {
Button { sheet = .add } label: { Label("Hinzufügen", systemImage: "plus") }
Spacer()
Button { sheet = .relocate(from: lots.first?.locationId) } label: {
Label("Umlagern", systemImage: "arrow.left.arrow.right")
}
.disabled(lots.isEmpty)
Spacer()
Button(role: .destructive) { sheet = .remove(loc: lots.first?.locationId) } label: {
Label("Entfernen", systemImage: "trash")
}
.disabled(lots.isEmpty)
}
.buttonStyle(.borderless)
.font(.callout)
}
if let removals, !removals.stats.isEmpty {
Section("Bereits entnommen") {
ForEach(removals.stats) { s in
LabeledContent(RemovalReasons.label(s.reason),
value: "\(formatAmount(s.quantity)) (\(s.count)×)")
}
}
}
}
.sheet(item: $sheet) { welche in
NavigationStack {
switch welche {
case .add:
ObjectAddSheet(product: product, locations: locations, einheit: einheit,
perform: { await afterAction() })
case .relocate(let from):
ObjectRelocateSheet(product: product, locations: locations, initialFrom: from,
perform: { await afterAction() })
case .remove(let loc):
ObjectRemoveSheet(product: product, locations: locations, einheit: einheit,
initialLoc: loc, perform: { await afterAction() })
}
}
}
.task(id: product.id) { await loadRemovals() }
.onChange(of: lots) { _ in Task { await loadRemovals() } }
}
private func loadRemovals() async {
removals = try? await APIClient.shared.productRemovals(id: product.id)
}
private func afterAction() async {
await onChanged()
await loadRemovals()
}
}
/// Eine Eingabe für ein selbst definiertes Feld, passend zum Feldtyp.
struct ObjectFieldRow: View {
let def: FieldDefinition
@Binding var value: String
private var titel: String { def.label + (def.required ? " *" : "") }
private var dateBinding: Binding<Date> {
Binding(
get: {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"
return f.date(from: value) ?? Date()
},
set: {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"
value = f.string(from: $0)
}
)
}
private var boolBinding: Binding<Bool> {
Binding(get: { value == "true" }, set: { value = $0 ? "true" : "false" })
}
var body: some View {
switch def.fieldType {
case "textarea":
VStack(alignment: .leading, spacing: 4) {
Text(titel).font(.caption).foregroundStyle(.secondary)
TextEditor(text: $value).frame(minHeight: 60)
}
case "number":
HStack {
Text(titel)
Spacer()
TextField("", text: $value)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(maxWidth: 120)
if let u = def.unit, !u.isEmpty { Text(u).foregroundStyle(.secondary) }
}
case "date":
HStack {
DatePicker(titel, selection: dateBinding, displayedComponents: .date)
if !value.isEmpty {
Button { value = "" } label: { Image(systemName: "xmark.circle.fill") }
.buttonStyle(.borderless).foregroundStyle(.secondary)
}
}
case "select":
Picker(titel, selection: $value) {
Text(" keine ").tag("")
ForEach(def.options, id: \.self) { Text($0).tag($0) }
}
case "boolean":
Toggle(titel, isOn: boolBinding)
default:
HStack {
Text(titel)
Spacer()
TextField("", text: $value).multilineTextAlignment(.trailing)
}
}
}
}
/// Auswahl eines Lagerorts (oder ohne").
private struct LocationPicker: View {
let title: String
let locations: [StorageLocation]
@Binding var selection: Int?
var body: some View {
Picker(title, selection: $selection) {
Text(" ohne Lagerort ").tag(Int?.none)
ForEach(locations) { loc in Text(loc.name).tag(Int?.some(loc.id)) }
}
}
}
/// Menge an einem Lagerort hinzufügen.
struct ObjectAddSheet: View {
let product: Product
let locations: [StorageLocation]
let einheit: String
var perform: () async -> Void
@Environment(\.dismiss) private var dismiss
@State private var locationId: Int?
@State private var quantity = ""
@State private var busy = false
@State private var error: String?
var body: some View {
Form {
Section {
LocationPicker(title: "Lagerort", locations: locations, selection: $locationId)
QuantityField(label: "Menge", text: $quantity, suffix: einheit)
}
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
Section {
Button(busy ? "Speichern…" : "Hinzufügen") { Task { await save() } }
.disabled(busy)
}
}
.navigationTitle("Menge hinzufügen")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
}
private func save() async {
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 }
do {
_ = try await APIClient.shared.objectCheckIn(
ObjectCheckInRequest(productId: product.id, quantity: menge,
unit: einheit, locationId: locationId))
await perform()
dismiss()
} catch { self.error = error.localizedDescription }
}
}
/// Menge von einem Lagerort zum anderen umbuchen.
struct ObjectRelocateSheet: View {
let product: Product
let locations: [StorageLocation]
let initialFrom: Int?
var perform: () async -> Void
@Environment(\.dismiss) private var dismiss
@State private var fromId: Int?
@State private var toId: Int?
@State private var quantity = ""
@State private var busy = false
@State private var error: String?
var body: some View {
Form {
Section {
LocationPicker(title: "Von", locations: locations, selection: $fromId)
LocationPicker(title: "Nach", locations: locations, selection: $toId)
QuantityField(label: "Menge", text: $quantity, suffix: product.unitName)
}
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
Section {
Button(busy ? "Umlagern…" : "Umlagern") { Task { await save() } }.disabled(busy)
}
}
.navigationTitle("Umlagern")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
.onAppear { fromId = initialFrom }
}
private func save() async {
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 }
do {
_ = try await APIClient.shared.relocate(
RelocateRequest(productId: product.id, quantity: menge,
fromLocationId: fromId, toLocationId: toId))
await perform()
dismiss()
} catch { self.error = error.localizedDescription }
}
}
/// Menge mit Pflicht-Grund aus dem Bestand entfernen.
struct ObjectRemoveSheet: View {
let product: Product
let locations: [StorageLocation]
let einheit: String
let initialLoc: Int?
var perform: () async -> Void
@Environment(\.dismiss) private var dismiss
@State private var locationId: Int?
@State private var quantity = ""
@State private var reason = "broken"
@State private var note = ""
@State private var busy = false
@State private var error: String?
var body: some View {
Form {
Section {
LocationPicker(title: "Lagerort", locations: locations, selection: $locationId)
QuantityField(label: "Menge", text: $quantity, suffix: einheit)
Picker("Grund", selection: $reason) {
ForEach(RemovalReasons.all, id: \.value) { Text($0.label).tag($0.value) }
}
LabeledField(label: "Notiz", text: $note)
}
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
Section {
Button(busy ? "Entfernen…" : "Entfernen") { Task { await save() } }
.disabled(busy)
.foregroundStyle(.red)
}
}
.navigationTitle("Aus Bestand entfernen")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
.onAppear { locationId = initialLoc }
}
private func save() async {
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 }
do {
_ = try await APIClient.shared.removeStock(
RemoveRequest(productId: product.id, quantity: menge, locationId: locationId,
reason: reason, note: note.isEmpty ? nil : note))
await perform()
dismiss()
} catch { self.error = error.localizedDescription }
}
}

View File

@@ -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