Setzt den Backend-Umbau in beiden Oberflaechen um. Die Genauigkeit gilt jeweils fuer den ganzen Einlager-Vorgang und nicht je Charge: Auf einer Packung steht entweder ein Tagesdatum oder nur Monat/Jahr, gemischt kommt das nicht vor. Vorbelegt wird sie aus dem Produkt, laesst sich aber im Vorgang umstellen. Web-UI: Umschalter im Kopf der Chargenliste; das Eingabefeld wechselt zwischen type="date" und type="month". Im Produktformular gibt es das neue Feld "MHD-Angabe" neben der Gebinde-Bezeichnung. Anzeige laeuft ueber den Settings-Context, damit das eingestellte Datumsformat erhalten bleibt und Monatsangaben ueberall als "09/2026" erscheinen (Auslagern, Uebersicht, Chargentabelle). Die Abgelaufen-Warnung prueft bei Monatsangaben gegen den Monatsletzten - sonst haette eine Packung schon am Monatsersten als abgelaufen gegolten. iOS: Auswahl "Tagesdatum / nur Monat/Jahr" im Einlagern-Formular und beim Anlegen eines Artikels. SwiftUI hat keinen DatePicker ohne Tag, deshalb ein eigener MonthYearPicker aus zwei Auswahlfeldern; die Jahresliste reicht zwei Jahre zurueck (bereits abgelaufene Ware) und fuenfzehn nach vorn (Konserven). Die Chargenauswahl beim Auslagern zeigt Monatsangaben ebenfalls ohne Tag. Getestet: Web-Build (vite) und iOS-Geraetebuild laufen fehlerfrei durch, die App ist auf dem iPhone installiert. Das Verhalten in der Oberflaeche ist noch nicht von Hand durchgeklickt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
145 lines
5.2 KiB
Swift
145 lines
5.2 KiB
Swift
import SwiftUI
|
||
|
||
/// 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?
|
||
var suggestion: LookupResult.Suggestion?
|
||
var onCreated: (Product) -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
@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 busy = false
|
||
@State private var error: String?
|
||
|
||
private let baseUnits = [("piece", "Stück"), ("gram", "Gramm"), ("milliliter", "Milliliter")]
|
||
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section("Artikel") {
|
||
TextField("Barcode", text: $barcode).keyboardType(.numberPad)
|
||
TextField("Name", text: $name)
|
||
TextField("Marke", text: $brand)
|
||
}
|
||
Section("Einheit") {
|
||
Picker("Basiseinheit", selection: $baseUnit) {
|
||
ForEach(baseUnits, id: \.0) { Text($0.1).tag($0.0) }
|
||
}
|
||
TextField("Packungsgröße (in Basiseinheit)", text: $packageSize)
|
||
.keyboardType(.decimalPad)
|
||
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")
|
||
}
|
||
}
|
||
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)
|
||
.onAppear(perform: prefill)
|
||
}
|
||
|
||
private func prefill() {
|
||
barcode = suggestion?.barcode ?? prefillBarcode ?? ""
|
||
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: groupId
|
||
)
|
||
)
|
||
onCreated(product)
|
||
dismiss()
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
}
|