Die Gruppen-Dropdowns waren flache, alphabetische Listen - man sah nicht, dass "Grillwurst" unter "Wurst" haengt. Ein gemeinsamer Helfer (gruppenOptionen) rollt den Graphen zum Baum aus und rueckt ein; eine Gruppe mit mehreren Obergruppen erscheint unter jeder, denn genau das ist ihr Sinn, und ausgewaehlt wird ohnehin dieselbe ID. Eingerueckt wird mit GESCHUETZTEN Leerzeichen - normale fasst der Browser in einem <option> zusammen, die Einrueckung waere sonst wirkungslos. Betroffen: Gruppen-Auswahl im Artikelformular (beide Zweige), beim Einlagern, die Ziel-Auswahl der Mindestbestaende und die Obergruppen-Mehrfachauswahl (die beim Suchen bewusst abflacht, weil eine Einrueckung ohne die Eltern in die Irre fuehrt). In der App dasselbe in beiden Gruppen-Pickern. Die Gruppen-Spalte der Artikelliste zeigt jetzt den Weg von oben statt eines Etiketts - "Wurst → Grillwurst", mit gedaempften Vorfahren wie bei der Kategorie. Gefiltert wird ueber ALLE Wege: wer auf "Wurst" filtert, sieht auch die Artikel aus den Untergruppen. Dafuer gibt es gruppenInfoMap als Gegenstueck zu categoryInfoMap, das mit mehreren Wegen je Gruppe umgehen kann. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
439 lines
19 KiB
Swift
439 lines
19 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] = [:]
|
||
// Text-Scan für Name/Marke (statt abtippen), wenn ein Produkt neu ist.
|
||
@State private var scanTarget: TextScanTarget?
|
||
|
||
enum TextScanTarget: Identifiable {
|
||
case name, brand
|
||
var id: Int { self == .name ? 0 : 1 }
|
||
var titel: String { self == .name ? "Name scannen" : "Marke scannen" }
|
||
}
|
||
|
||
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"]
|
||
|
||
// Umrechnung in die andere Art („3 Stück ≙ 250 g") – siehe ProductDetailView.
|
||
@State private var zweitBasis = ""
|
||
@State private var zweitAnzahl = ""
|
||
@State private var zweitMenge = ""
|
||
|
||
/// 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)
|
||
if TextScanView.isSupported {
|
||
Button { scanTarget = .name } label: { Label("Name scannen", systemImage: "text.viewfinder") }
|
||
}
|
||
LabeledField(label: "Marke", text: $brand)
|
||
if TextScanView.isSupported {
|
||
Button { scanTarget = .brand } label: { Label("Marke scannen", systemImage: "text.viewfinder") }
|
||
}
|
||
}
|
||
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)
|
||
// Als Baum, damit sichtbar ist, was unter was haengt.
|
||
ForEach(gruppenOptionen(groups)) { eintrag in
|
||
Text(eintrag.label).tag(Int?.some(eintrag.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")
|
||
}
|
||
}
|
||
|
||
// Dieselbe Ware in der ANDEREN Art lesbar machen. Damit zaehlt
|
||
// der Artikel auch in Gruppen, die in dieser Einheit rechnen.
|
||
Section {
|
||
Picker("Umrechnen in", selection: Binding(
|
||
get: { zweitBasis },
|
||
set: { waehleZweitart($0) }
|
||
)) {
|
||
Text("– keine –").tag("")
|
||
ForEach(zweitBasisOptionen, id: \.self) { basis in
|
||
Text(DisplaySettings.baseUnitLabel(basis)).tag(basis)
|
||
}
|
||
}
|
||
if !zweitBasis.isEmpty {
|
||
QuantityField(label: "Menge", text: $zweitAnzahl,
|
||
suffix: DisplaySettings.baseUnitLabel(baseUnit))
|
||
QuantityField(label: zweitVerb, text: $zweitMenge,
|
||
suffix: DisplaySettings.baseUnitLabel(zweitBasis))
|
||
}
|
||
} header: {
|
||
Text("\(zweitTitel) (optional)")
|
||
} footer: {
|
||
Text("Zweite Lesart desselben Artikels – z.B. „3 Stück wiegen 250 g“. "
|
||
+ "Lässt sich auch später am Artikel nachtragen.")
|
||
}
|
||
}
|
||
// 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()
|
||
}
|
||
.fullScreenCover(item: $scanTarget) { target in
|
||
NavigationStack {
|
||
TextScanView(titel: target.titel) { text in
|
||
if target == .name { name = text } else { brand = text }
|
||
}
|
||
}
|
||
}
|
||
.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
|
||
}
|
||
|
||
/// Basiseinheiten der ANDEREN Arten – eine Bruecke auf die eigene Art
|
||
/// haette nichts umzurechnen.
|
||
private var zweitBasisOptionen: [String] {
|
||
["piece", "gram", "milliliter"].filter { $0 != baseUnit }
|
||
}
|
||
|
||
private var zweitTitel: String {
|
||
switch zweitBasis {
|
||
case "gram": return "Gewicht"
|
||
case "milliliter": return "Volumen"
|
||
case "piece": return "Stückzahl"
|
||
default: return "Umrechnung"
|
||
}
|
||
}
|
||
|
||
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"
|
||
}
|
||
}
|
||
|
||
/// Zielart waehlen und die linke Zahl EINMALIG mit der Packungsgroesse
|
||
/// vorbelegen – im Normalfall ist sie genau das.
|
||
private func waehleZweitart(_ basis: String) {
|
||
zweitBasis = basis
|
||
if !basis.isEmpty, zweitAnzahl.isEmpty {
|
||
zweitAnzahl = packageSize.isEmpty ? "1" : packageSize
|
||
}
|
||
}
|
||
|
||
/// Nur eine vollstaendige Angabe ist eine Bruecke – eine halbe waere keine.
|
||
private var zweitVollstaendig: Bool {
|
||
guard !zweitBasis.isEmpty,
|
||
let n = Double(zweitAnzahl.replacingOccurrences(of: ",", with: ".")),
|
||
let m = Double(zweitMenge.replacingOccurrences(of: ",", with: ".")) else { return false }
|
||
return n > 0 && m > 0
|
||
}
|
||
|
||
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) }
|
||
// „3 x 80 g" schlaegt die Umrechnung gleich mit vor.
|
||
if let basis = suggestion.secondaryBase,
|
||
let anzahl = suggestion.secondaryCount,
|
||
let menge = suggestion.secondaryAmount {
|
||
zweitBasis = basis
|
||
zweitAnzahl = formatAmount(anzahl)
|
||
zweitMenge = formatAmount(menge)
|
||
}
|
||
}
|
||
}
|
||
|
||
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(),
|
||
secondaryBase: zweitVollstaendig ? zweitBasis : nil,
|
||
secondaryCount: zweitVollstaendig
|
||
? Double(zweitAnzahl.replacingOccurrences(of: ",", with: ".")) : nil,
|
||
secondaryAmount: zweitVollstaendig
|
||
? Double(zweitMenge.replacingOccurrences(of: ",", with: ".")) : nil
|
||
)
|
||
)
|
||
// 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
|
||
}
|
||
}
|
||
}
|