Die App konnte fuenf Dinge, die Web-Oberflaeche vierzehn. Alles darueber hinaus
- nachsehen was gestern passiert ist, einen Lagerort anlegen - zwang zurueck an
den Browser.
Mehrere Server: Statt einer Adresse und einem Token kennt die App jetzt
beliebig viele Profile, jedes mit eigenem Token im Keychain. Ein Wechsel ist
damit ein Tipp und meldet an keinem der Server ab. Jedes Profil hat einen frei
waehlbaren Namen ("Zuhause"), der oben in der App steht; ohne Angabe faellt er
auf den Rechnernamen zurueck. Eine bestehende Anmeldung wird beim ersten Start
in ein Profil ueberfuehrt - ohne das waere man nach dem Update abgemeldet.
Statt einer Startseite mit Kacheln gibt es vier Tabs: Start, Scannen, Listen
und - nur fuer Administratoren, wie im Web - Verwaltung. Neu darin sind eine
Uebersicht mit den Kennzahlen des Servers, die per Tipp in die passende Liste
fuehren, der Bewegungsverlauf (auch je Artikel, weil /movements danach filtern
kann) und die Stammdaten: Lagerorte, Einheiten, Gebinde, Kategorien und
Gruppen. Die fuenf teilen sich eine Ansicht und unterscheiden sich nur darin,
wie geladen und geschrieben wird.
Zwei Dinge, die beim Pruefen gegen einen echten Server auffielen: Der Server
schickt Zeitstempel in UTC, haengt hinter SQLite aber keine Zeitzone an. Naiv
gelesen haette die App jede Uhrzeit um die eigene Zeitverschiebung daneben
angezeigt, deshalb wird die nackte Form ausdruecklich als UTC gelesen. Und die
Anzeigeeinstellungen gehoeren dem Server, werden beim Wechsel also neu geladen.
Bewusst nicht dabei: Benutzer, Einstellungen, Branding und Import/Export. Die
bleiben vorerst im Web.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
300 lines
10 KiB
Swift
300 lines
10 KiB
Swift
import SwiftUI
|
||
|
||
/// Produkt ansehen und ändern, dazu die Chargen korrigieren oder löschen.
|
||
struct ProductDetailView: View {
|
||
let product: Product
|
||
|
||
@EnvironmentObject private var display: DisplaySettings
|
||
|
||
@State private var current: Product
|
||
@State private var lots: [Lot] = []
|
||
@State private var busy = false
|
||
@State private var error: String?
|
||
@State private var status: String?
|
||
|
||
// Bearbeitbare Felder
|
||
@State private var name = ""
|
||
@State private var brand = ""
|
||
@State private var packageSize = ""
|
||
@State private var packageLabel = ""
|
||
@State private var datePrecision = "day"
|
||
@State private var groupId: Int?
|
||
@State private var categoryId: Int?
|
||
@State private var groups: [GroupItem] = []
|
||
@State private var categories: [CategoryItem] = []
|
||
|
||
@State private var editLot: Lot?
|
||
|
||
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
|
||
|
||
init(product: Product) {
|
||
self.product = product
|
||
_current = State(initialValue: product)
|
||
}
|
||
|
||
var body: some View {
|
||
Form {
|
||
if let status {
|
||
Section { Text(status).foregroundStyle(.green).font(.callout) }
|
||
}
|
||
if let error {
|
||
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)
|
||
}
|
||
}
|
||
|
||
// Beschriftungen links, Werte rechts: Ohne sie las man nur noch
|
||
// "Bratbutter" / "M-Classic" / "450" ohne jeden Zusammenhang.
|
||
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")
|
||
}
|
||
}
|
||
|
||
Section {
|
||
CategoryPicker(categories: categories, selection: $categoryId)
|
||
} footer: {
|
||
Text("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))
|
||
}
|
||
}
|
||
} footer: {
|
||
Text("Gruppe: zählt Bestände mehrerer Marken zusammen. Der EAN-Code "
|
||
+ "wandert bei einem Wechsel mit.")
|
||
}
|
||
|
||
Section("Erkennung") {
|
||
LabeledContent("Barcode", value: current.barcode ?? "–")
|
||
if !current.barcodes.isEmpty {
|
||
LabeledContent("Weitere Codes", value: "\(current.barcodes.count)")
|
||
}
|
||
}
|
||
|
||
Section {
|
||
Button(busy ? "Speichern…" : "Änderungen speichern") { Task { await save() } }
|
||
.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)
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
|
||
Section {
|
||
// Derselbe Verlauf wie im Listen-Tab, nur auf diesen Artikel
|
||
// gefiltert.
|
||
NavigationLink { HistoryView(productId: current.id) } label: {
|
||
Label("Verlauf dieses Artikels", systemImage: "clock.arrow.circlepath")
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle(current.name)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.sheet(item: $editLot) { lot in
|
||
NavigationStack {
|
||
LotEditView(lot: lot, product: current) {
|
||
Task { await reload() }
|
||
}
|
||
}
|
||
}
|
||
.task {
|
||
fillForm()
|
||
await reload()
|
||
}
|
||
}
|
||
|
||
private func fillForm() {
|
||
name = current.name
|
||
brand = current.brand ?? ""
|
||
packageSize = current.packageSize.map { formatAmount($0) } ?? ""
|
||
packageLabel = current.packageLabel ?? ""
|
||
datePrecision = current.datePrecision == "month" ? "month" : "day"
|
||
groupId = current.groupId
|
||
categoryId = current.categoryId
|
||
}
|
||
|
||
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)) ?? []
|
||
if let frisch = try? await APIClient.shared.product(id: current.id) {
|
||
current = frisch
|
||
}
|
||
}
|
||
|
||
private func save() async {
|
||
error = nil
|
||
status = nil
|
||
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
|
||
)
|
||
)
|
||
status = "Gespeichert."
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
private func deleteLots(at indexSet: IndexSet) async {
|
||
error = nil
|
||
for index in indexSet {
|
||
do {
|
||
try await APIClient.shared.deleteLot(id: lots[index].id)
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
await reload()
|
||
}
|
||
}
|
||
|
||
/// Menge und MHD einer Charge korrigieren.
|
||
struct LotEditView: View {
|
||
let lot: Lot
|
||
let product: Product
|
||
var onSaved: () -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
@State private var quantity = ""
|
||
@State private var hasDate = false
|
||
@State private var bestBefore = Date()
|
||
@State private var precision = "day"
|
||
@State private var busy = false
|
||
@State private var error: String?
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section("Menge") {
|
||
QuantityField(label: "Menge", text: $quantity,
|
||
suffix: product.articleUnitLabel)
|
||
}
|
||
|
||
Section("MHD") {
|
||
Toggle("MHD angeben", isOn: $hasDate)
|
||
if hasDate {
|
||
Picker("Angabe", selection: $precision) {
|
||
Text("Tagesdatum").tag("day")
|
||
Text("nur Monat/Jahr").tag("month")
|
||
}
|
||
if precision == "month" {
|
||
MonthYearPicker(date: $bestBefore)
|
||
} else {
|
||
DatePicker("MHD", selection: $bestBefore, displayedComponents: .date)
|
||
}
|
||
}
|
||
}
|
||
|
||
if let error {
|
||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||
}
|
||
|
||
Section {
|
||
Button(busy ? "Speichern…" : "Speichern") { Task { await save() } }
|
||
.disabled(busy)
|
||
}
|
||
}
|
||
.navigationTitle("Charge ändern")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
|
||
}
|
||
.onAppear(perform: fill)
|
||
}
|
||
|
||
private func fill() {
|
||
quantity = formatAmount(lot.quantity / product.articleUnitFactor)
|
||
precision = lot.bestBeforePrecision == "month" ? "month" : "day"
|
||
if let raw = lot.bestBefore {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "yyyy-MM-dd"
|
||
if let datum = formatter.date(from: raw) {
|
||
bestBefore = datum
|
||
hasDate = true
|
||
}
|
||
}
|
||
}
|
||
|
||
private func save() async {
|
||
error = nil
|
||
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 }
|
||
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "yyyy-MM-dd"
|
||
let datum = precision == "month" ? bestBefore.startOfMonth : bestBefore
|
||
|
||
do {
|
||
// Das Backend rechnet Chargenmengen in Basiseinheiten.
|
||
_ = try await APIClient.shared.updateLot(
|
||
id: lot.id,
|
||
LotUpdateRequest(
|
||
quantity: menge * product.articleUnitFactor,
|
||
bestBefore: hasDate ? formatter.string(from: datum) : nil,
|
||
bestBeforePrecision: precision
|
||
)
|
||
)
|
||
onSaved()
|
||
dismiss()
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
}
|