Files
Vorrania/ios/Sources/CheckOutView.swift
Scarriffle 71d52917e4 App: Zweiteinheit erfassen und anzeigen
Eigener Abschnitt im Artikel mit "Menge [Stk] entspricht [Gramm] das sind
[250]" und der ausgerechneten Kontrolle in der Fusszeile. Nur Charge-Artikel,
und nur die anderen Arten stehen zur Wahl - eine Bruecke auf die eigene Art
haette nichts umzurechnen.

ProductUpdateRequest bekommt sendSecondary nach dem Vorbild von sendMinStock:
ein PATCH aus einer anderen Maske soll die Bruecke nicht unbeabsichtigt
loeschen. Die Einheitenauswahl beim Ein- und Auslagern nimmt bei hinterlegter
Bruecke die Gegenart mit auf, der Bestand steht in beiden Lesarten
("6 Stueck · 500 g").

Alle neuen Product-Felder sind Optionals, damit die App auch gegen einen
aelteren Server laeuft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 00:42:52 +02:00

229 lines
8.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
struct CheckOutView: View {
@Environment(\.dismiss) private var dismiss
@State private var paused = false
@State private var torchOn = false
@State private var torchLocked = false
@State private var status: String?
@State private var error: String?
@State private var product: Product?
@State private var units: [Unit] = []
@State private var manualCodeShown = false
@State private var manualCode = ""
@State private var searchShown = false
var body: some View {
// Kamera als Feld oben, siehe CheckInView.
ScrollView {
VStack(spacing: 14) {
ScannerView(onCode: { code in Task { await resolve(code) } },
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
.aspectRatio(4.0 / 3.0, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
Text("Barcode vor die Kamera halten")
.font(.caption).foregroundStyle(.secondary)
if let status { banner(status, color: .green) }
if let error { banner(error, color: .red) }
VStack(spacing: 10) {
Button {
paused = true
searchShown = true
} label: {
Label("Artikel suchen", systemImage: "magnifyingglass")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Button {
paused = true
manualCodeShown = true
} label: {
Label("EAN manuell", systemImage: "keyboard")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
.padding(.horizontal)
}
.padding(.vertical)
}
.navigationTitle("Auslagern")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) }
}
.task { units = (try? await APIClient.shared.units()) ?? [] }
.alert("EAN eingeben", isPresented: $manualCodeShown) {
TextField("z.B. 8076809572569", text: $manualCode)
.keyboardType(.numberPad)
Button("Suchen") {
let code = manualCode
manualCode = ""
Task { await resolve(code) }
}
Button("Abbrechen", role: .cancel) { paused = false }
}
.sheet(isPresented: $searchShown) {
NavigationStack {
ProductSearchView { picked in
searchShown = false
product = picked
}
}
}
.sheet(item: $product) { item in
NavigationStack {
CheckOutFormView(product: item, units: units) { message in
status = message
product = nil
paused = false
}
}
}
}
private func banner(_ text: String, color: Color) -> some View {
Text(text)
.font(.callout)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(color.opacity(0.9))
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal)
}
private func resolve(_ code: String) async {
paused = true
error = nil
status = nil
do {
let result = try await APIClient.shared.lookup(barcode: code)
if let existing = result.existingProduct {
product = existing
} else {
error = "Kein bekanntes Produkt zu diesem Code im Lager."
paused = false
}
} catch {
self.error = error.localizedDescription
paused = false
}
}
}
/// Auslagern: Menge, Einheit und optional eine bestimmte Charge (sonst FEFO).
struct CheckOutFormView: View {
let product: Product
let units: [Unit]
var onDone: (String) -> Void
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var display: DisplaySettings
@State private var quantity = "1"
@State private var unit = ""
@State private var lots: [Lot] = []
@State private var lotId: Int?
@State private var busy = false
@State private var error: String?
private var unitOptions: [UnitOption] {
// Bei hinterlegter Zweiteinheit auch die Einheiten der Gegenart das
// Backend rechnet sie ueber die Bruecke am Artikel um.
var arten: Set<String> = [product.kind]
if product.zweitFaktor != nil, let k = product.zweitKind { arten.insert(k) }
var options = units
.filter { arten.contains($0.kind) }
.map { UnitOption(value: $0.name, label: $0.name) }
if let size = product.packageSize, size > 0 {
// Angezeigt wird "Glas"/"Dose", geschickt wird "package".
options.append(UnitOption(value: UnitOption.packageValue,
label: product.packageLabel ?? "Packung"))
}
return options
}
var body: some View {
Form {
Section {
VStack(alignment: .leading) {
Text(product.name).font(.headline)
Text("Verfügbar: \(format(product.stockInArticleUnits)) \(product.articleUnitLabel)")
.font(.caption).foregroundStyle(.secondary)
}
}
Section("Charge") {
Picker("Charge", selection: $lotId) {
Text("Automatisch (zuerst ablaufende)").tag(Int?.none)
ForEach(lots) { lot in
Text(label(for: lot)).tag(Int?.some(lot.id))
}
}
}
Section("Menge") {
QuantityField(label: "Menge", text: $quantity)
Picker("Einheit", selection: $unit) {
ForEach(unitOptions) { Text($0.label).tag($0.value) }
}
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
Section {
Button(busy ? "Speichern…" : "Auslagern") { Task { await submit() } }
.disabled(busy)
}
}
.navigationTitle("Auslagern")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
}
.task {
unit = (product.packageSize ?? 0) > 0 ? UnitOption.packageValue : product.unitName
lots = (try? await APIClient.shared.lots(productId: product.id)) ?? []
}
}
private func label(for lot: Lot) -> String {
let amount = format(lot.quantity / product.articleUnitFactor)
return "\(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision)) · \(amount) \(product.articleUnitLabel)"
}
private func format(_ value: Double) -> String {
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
}
private func submit() async {
error = nil
guard let amount = Double(quantity.replacingOccurrences(of: ",", with: ".")), amount > 0 else {
error = "Bitte eine Menge größer 0 angeben."
return
}
busy = true
defer { busy = false }
do {
let response = try await APIClient.shared.checkOut(
CheckOutRequest(productId: product.id, quantity: amount, unit: unit, lotId: lotId)
)
let total = response.productStock / product.articleUnitFactor
onDone("Ausgelagert\nNeuer Bestand: \(format(total)) \(display.articleUnit(product, count: total))")
dismiss()
} catch {
self.error = error.localizedDescription
}
}
}