Beim Neuanlegen eines Gegenstands (z.B. T-Shirt) werden jetzt die eigenen Felder der gewaehlten Kategorie (Groesse, Farbe, ...) geladen und angezeigt - vorher tauchten sie erst in der Detailansicht nach dem Anlegen auf. NewProductRequest traegt die Werte via field_values mit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
332 lines
14 KiB
Swift
332 lines
14 KiB
Swift
import SwiftUI
|
||
import PhotosUI
|
||
|
||
/// Artikel per Namenssuche auswählen (Alternative zum Scannen).
|
||
struct ProductSearchView: View {
|
||
var onPick: (Product) -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var query = ""
|
||
@State private var results: [Product] = []
|
||
@State private var busy = false
|
||
|
||
var body: some View {
|
||
List {
|
||
ForEach(results) { product in
|
||
Button {
|
||
onPick(product)
|
||
dismiss()
|
||
} label: {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(product.name)
|
||
Text("\(formatted(product.stockInArticleUnits)) \(product.articleUnitLabel)")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
if results.isEmpty && !busy {
|
||
Text("Keine Treffer").foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.searchable(text: $query, prompt: "Artikel suchen")
|
||
.onSubmit(of: .search) { Task { await search() } }
|
||
.task { await search() }
|
||
.onChange(of: query) { _ in Task { await search() } }
|
||
.navigationTitle("Artikel suchen")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
|
||
}
|
||
}
|
||
|
||
private func formatted(_ value: Double) -> String {
|
||
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
|
||
}
|
||
|
||
private func search() async {
|
||
busy = true
|
||
defer { busy = false }
|
||
results = (try? await APIClient.shared.searchProducts(query)) ?? []
|
||
}
|
||
}
|
||
|
||
/// Neues Produkt anlegen – bei Bedarf mit den Daten von Open Food Facts vorbefüllt.
|
||
struct ProductFormView: View {
|
||
let prefillBarcode: String?
|
||
let groupId: Int?
|
||
/// Aus der Open-Food-Facts-Einordnung vorgeschlagene Kategorie.
|
||
var categoryId: Int?
|
||
var suggestion: LookupResult.Suggestion?
|
||
/// Vorbelegter Typ ("food"/"object"), z.B. aus der Lebensmittel- oder
|
||
/// Gegenstände-Liste heraus. Ein Kategorie-Vorschlag hat weiter Vorrang.
|
||
var initialType: String?
|
||
var onCreated: (Product) -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
@EnvironmentObject private var session: Session
|
||
|
||
@State private var barcode = ""
|
||
@State private var name = ""
|
||
@State private var brand = ""
|
||
@State private var baseUnit = "piece"
|
||
@State private var packageSize = ""
|
||
@State private var packageLabel = ""
|
||
@State private var datePrecision = "day"
|
||
@State private var selectedGroupId: Int?
|
||
@State private var selectedCategoryId: Int?
|
||
// Umschalter Lebensmittel/Gegenstand: filtert die Kategorieauswahl.
|
||
@State private var modus = "object"
|
||
// Verwaltungsart eines Gegenstands: "count" (Menge je Lagerort),
|
||
// "individual" (Einzelstücke) oder "bulk" (Verbrauchsgegenstand wie Lebensmittel).
|
||
@State private var objMode = "count"
|
||
@State private var groups: [GroupItem] = []
|
||
@State private var categories: [CategoryItem] = []
|
||
@State private var units: [Unit] = []
|
||
@State private var newGroupShown = false
|
||
@State private var busy = false
|
||
@State private var error: String?
|
||
// Foto schon beim Anlegen: erst nach dem Anlegen hochladen (ID nötig).
|
||
@State private var pendingImage: UIImage?
|
||
@State private var showCamera = false
|
||
@State private var pickerItem: PhotosPickerItem?
|
||
// Eigene Felder der (Gegenstands-)Kategorie – schon beim Anlegen ausfüllbar.
|
||
@State private var fieldDefs: [FieldDefinition] = []
|
||
@State private var fieldValues: [String: String] = [:]
|
||
|
||
private let baseUnits = [("piece", "Stück"), ("gram", "Gramm"), ("milliliter", "Milliliter")]
|
||
// Beschriftungen kommen sonst aus DisplaySettings.baseUnitLabel.
|
||
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
|
||
|
||
/// Nutzt die Lebensmittel-Pfade (Einheit, MHD, Charge): Lebensmittel ODER
|
||
/// Verbrauchsgegenstand.
|
||
private var foodLike: Bool { modus == "food" || objMode == "bulk" }
|
||
/// Gruppe anbieten: Lebensmittel, Verbrauchsgegenstand oder Menge je Lagerort
|
||
/// (nicht bei Einzelstücken).
|
||
private var showGroup: Bool { modus == "food" || (modus == "object" && objMode != "individual") }
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section("Artikel") {
|
||
LabeledField(label: "Barcode", text: $barcode, keyboard: .numberPad)
|
||
LabeledField(label: "Name", text: $name)
|
||
LabeledField(label: "Marke", text: $brand)
|
||
}
|
||
Section {
|
||
Picker("Typ", selection: $modus) {
|
||
Text("Lebensmittel").tag("food")
|
||
Text("Gegenstand").tag("object")
|
||
}
|
||
.pickerStyle(.segmented)
|
||
CategoryPicker(categories: categories, selection: $selectedCategoryId, tracking: modus)
|
||
if modus == "object" {
|
||
Picker("Verwaltung", selection: $objMode) {
|
||
Text("Menge je Lagerort").tag("count")
|
||
Text("Einzelstücke").tag("individual")
|
||
Text("Verbrauchsgegenstand").tag("bulk")
|
||
}
|
||
}
|
||
} footer: {
|
||
Text(modus == "object"
|
||
? (objMode == "individual"
|
||
? "Einzelstücke: jedes Stück mit eigener UID/QR, Kaufdatum, Garantie."
|
||
: objMode == "bulk"
|
||
? "Verbrauchsgegenstand: wie ein Lebensmittel als Charge mit Menge und Einheit (z.B. Sonnencreme in ml) – nur ohne eindeutigen Code."
|
||
: "Gegenstand: Menge je Lagerort und eigene Felder.")
|
||
: "Lebensmittel: Chargen mit Mindesthaltbarkeit.")
|
||
}
|
||
.onChange(of: modus) { neu in
|
||
// Passt die gewählte Kategorie nicht mehr zum Typ, Auswahl leeren.
|
||
if let cid = selectedCategoryId,
|
||
categories.first(where: { $0.id == cid })?.tracking != neu {
|
||
selectedCategoryId = nil
|
||
}
|
||
}
|
||
// Gruppe: Lebensmittel, Verbrauchsgegenstand und Menge je Lagerort
|
||
// (nur Einzelstücke bleiben außen vor).
|
||
if showGroup {
|
||
Section {
|
||
Picker("Gruppe", selection: $selectedGroupId) {
|
||
Text("– keine –").tag(Int?.none)
|
||
ForEach(groups) { gruppe in
|
||
Text(gruppe.name).tag(Int?.some(gruppe.id))
|
||
}
|
||
}
|
||
if session.isAdmin {
|
||
Button {
|
||
newGroupShown = true
|
||
} label: {
|
||
Label("Neue Gruppe anlegen", systemImage: "plus")
|
||
}
|
||
}
|
||
} footer: {
|
||
Text("Gruppe: zählt Bestände mehrerer Artikel zusammen. Der EAN-Code "
|
||
+ "dieses Artikels erscheint danach automatisch bei der Gruppe.")
|
||
}
|
||
}
|
||
// Einheit + MHD nur für Lebensmittel und Verbrauchsgegenstände (Charge).
|
||
if foodLike {
|
||
Section("Einheit") {
|
||
Picker("Basiseinheit", selection: $baseUnit) {
|
||
ForEach(baseUnits, id: \.0) { Text($0.1).tag($0.0) }
|
||
}
|
||
QuantityField(label: "Packungsgröße", text: $packageSize,
|
||
suffix: DisplaySettings.baseUnitLabel(baseUnit))
|
||
Picker("Bezeichnung", selection: $packageLabel) {
|
||
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
|
||
}
|
||
}
|
||
Section("MHD") {
|
||
Picker("Angabe", selection: $datePrecision) {
|
||
Text("Tagesdatum").tag("day")
|
||
Text("nur Monat/Jahr").tag("month")
|
||
}
|
||
}
|
||
}
|
||
// Eigene Felder der Gegenstands-Kategorie (z.B. Größe, Farbe beim
|
||
// T-Shirt) – schon beim Anlegen ausfüllbar, nicht erst danach.
|
||
if !fieldDefs.isEmpty {
|
||
Section("Eigene Felder") {
|
||
ForEach(fieldDefs) { def in
|
||
ObjectFieldRow(def: def, value: fieldBinding(def))
|
||
}
|
||
}
|
||
}
|
||
// Foto direkt beim Anlegen – z.B. wenn Open Food/Products Facts keins
|
||
// liefert. Wird nach dem Anlegen hochgeladen (dann gibt es eine ID).
|
||
Section("Foto") {
|
||
if let pendingImage {
|
||
Image(uiImage: pendingImage)
|
||
.resizable().scaledToFit()
|
||
.frame(maxWidth: .infinity).frame(maxHeight: 200)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
Button { showCamera = true } label: { Label("Foto machen", systemImage: "camera") }
|
||
PhotosPicker(selection: $pickerItem, matching: .images) {
|
||
Label("Aus Galerie", systemImage: "photo.on.rectangle")
|
||
}
|
||
if pendingImage != nil {
|
||
Button(role: .destructive) { pendingImage = nil } label: {
|
||
Label("Foto entfernen", systemImage: "trash")
|
||
}
|
||
}
|
||
}
|
||
if let error {
|
||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||
}
|
||
Section {
|
||
Button(busy ? "Anlegen…" : "Anlegen & weiter") { Task { await create() } }
|
||
.disabled(busy || name.isEmpty)
|
||
}
|
||
}
|
||
.navigationTitle("Neuer Artikel")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.fullScreenCover(isPresented: $showCamera) {
|
||
CameraPicker(isPresented: $showCamera) { image in pendingImage = image }
|
||
.ignoresSafeArea()
|
||
}
|
||
.onChange(of: pickerItem) { item in
|
||
guard let item else { return }
|
||
Task {
|
||
if let data = try? await item.loadTransferable(type: Data.self),
|
||
let ui = UIImage(data: data) {
|
||
pendingImage = ui
|
||
}
|
||
pickerItem = nil
|
||
}
|
||
}
|
||
.onChange(of: selectedCategoryId) { cid in
|
||
// Bei einer Gegenstands-Kategorie deren (vererbte) Felder nachladen.
|
||
Task {
|
||
if let cid, categories.first(where: { $0.id == cid })?.isObject == true {
|
||
fieldDefs = (try? await APIClient.shared.categoryFields(categoryId: cid)) ?? []
|
||
} else {
|
||
fieldDefs = []
|
||
}
|
||
}
|
||
}
|
||
.onAppear(perform: prefill)
|
||
.task {
|
||
groups = (try? await APIClient.shared.groups()) ?? []
|
||
categories = (try? await APIClient.shared.categories()) ?? []
|
||
units = (try? await APIClient.shared.units()) ?? []
|
||
// Kam eine Kategorie aus dem Barcode-Vorschlag, den Typ dazu setzen.
|
||
if let cid = selectedCategoryId,
|
||
let t = categories.first(where: { $0.id == cid })?.tracking {
|
||
modus = t
|
||
}
|
||
}
|
||
.sheet(isPresented: $newGroupShown) {
|
||
NavigationStack {
|
||
NewGroupSheet(units: units) { created in
|
||
groups.append(created)
|
||
selectedGroupId = created.id
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func fieldBinding(_ def: FieldDefinition) -> Binding<String> {
|
||
let key = String(def.id)
|
||
return Binding(get: { fieldValues[key] ?? "" }, set: { fieldValues[key] = $0 })
|
||
}
|
||
|
||
/// Werte der geltenden eigenen Felder als {feld_id: Wert} – nil, wenn keine.
|
||
private func eigeneFeldwerte() -> [String: String?]? {
|
||
guard !fieldDefs.isEmpty else { return nil }
|
||
var fv: [String: String?] = [:]
|
||
for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" }
|
||
return fv
|
||
}
|
||
|
||
private func prefill() {
|
||
if let initialType { modus = initialType }
|
||
barcode = suggestion?.barcode ?? prefillBarcode ?? ""
|
||
// Aus dem gescannten Code vorgeschlagene Gruppe und Kategorie
|
||
// uebernehmen. Beides bleibt aenderbar - gespeichert wird erst mit
|
||
// "Anlegen & weiter".
|
||
selectedGroupId = groupId
|
||
selectedCategoryId = categoryId
|
||
if let suggestion {
|
||
name = suggestion.name
|
||
brand = suggestion.brand ?? ""
|
||
baseUnit = suggestion.baseUnit ?? "piece"
|
||
if let size = suggestion.packageSize { packageSize = String(size) }
|
||
}
|
||
}
|
||
|
||
private func create() async {
|
||
error = nil
|
||
busy = true
|
||
defer { busy = false }
|
||
do {
|
||
let product = try await APIClient.shared.createProduct(
|
||
NewProductRequest(
|
||
barcode: barcode.isEmpty ? nil : barcode,
|
||
name: name,
|
||
brand: brand.isEmpty ? nil : brand,
|
||
imageUrl: suggestion?.imageUrl,
|
||
baseUnit: baseUnit,
|
||
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
|
||
packageLabel: packageLabel.isEmpty ? nil : packageLabel,
|
||
datePrecision: datePrecision,
|
||
groupId: selectedGroupId,
|
||
categoryId: selectedCategoryId,
|
||
individual: modus == "object" ? (objMode == "individual") : nil,
|
||
bulk: modus == "object" ? (objMode == "bulk") : nil,
|
||
fieldValues: eigeneFeldwerte()
|
||
)
|
||
)
|
||
// Vorgemerktes Foto hochladen – ein Fehler darf das Anlegen nicht
|
||
// zunichtemachen, der Artikel existiert bereits.
|
||
if let pendingImage, let data = productImageJPEG(pendingImage) {
|
||
_ = try? await APIClient.shared.uploadProductImage(
|
||
id: product.id, data: data, contentType: "image/jpeg")
|
||
}
|
||
onCreated(product)
|
||
dismiss()
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
}
|