Auslagern-Kachel: "arrow.up.from.line" existiert als SF Symbol nicht, deshalb blieb das Icon leer und man sah nur die eingefaerbte Kachelflaeche. Ersetzt durch "arrow.up.to.line", das Gegenstueck zum "arrow.down.to.line" beim Einlagern. Alle uebrigen im Projekt verwendeten Symbolnamen wurden gegen die Symbolliste des Systems geprueft und sind gueltig. App-Icon: Das mitgelieferte AppIcon.icon (Icon-Composer-Format) wird als Ressource eingebunden und ueber ASSETCATALOG_COMPILER_APPICON_NAME gesetzt. Die Icon-Eintraege landen korrekt in der selbstgepflegten Info.plist. Barcode-Erkennung, nachdem ein dunkelgruener Code auf weiss nicht gelesen wurde: - Aufloesung auf 1920x1080 statt der Voreinstellung, damit kontrastarme und kleine Codes ueberhaupt sauber aufgeloest werden. - Dauerhafter Autofokus mit Beschraenkung auf den Nahbereich; weiches Nachfuehren aus, da es hier nur verzoegert. - Antippen stellt gezielt auf eine Stelle scharf und misst dort die Belichtung. - Licht-Schalter in der Navigationsleiste beider Scan-Bildschirme; beim Verlassen wird das Licht sicher wieder ausgeschaltet. - Mehr Symbologien (ITF-14, DataMatrix, PDF417, Aztec, Code93). Es werden nur die Typen gesetzt, die die Kamera meldet - sonst wirft AVFoundation zur Laufzeit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
214 lines
7.3 KiB
Swift
214 lines
7.3 KiB
Swift
import SwiftUI
|
|
|
|
struct CheckOutView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var paused = false
|
|
@State private var torchOn = 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 {
|
|
ZStack(alignment: .bottom) {
|
|
ScannerView(onCode: { code in Task { await resolve(code) } },
|
|
isPaused: $paused, torchOn: $torchOn)
|
|
.ignoresSafeArea()
|
|
|
|
VStack(spacing: 10) {
|
|
if let status { banner(status, color: .green) }
|
|
if let error { banner(error, color: .red) }
|
|
|
|
HStack(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(.bottom, 8)
|
|
}
|
|
}
|
|
.navigationTitle("Auslagern")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
|
|
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn) }
|
|
}
|
|
.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
|
|
|
|
@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: [String] {
|
|
var options = units.filter { $0.kind == product.kind }.map(\.name)
|
|
if let size = product.packageSize, size > 0 {
|
|
options.append(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") {
|
|
TextField("Menge", text: $quantity).keyboardType(.decimalPad)
|
|
Picker("Einheit", selection: $unit) {
|
|
ForEach(unitOptions, id: \.self) { Text($0).tag($0) }
|
|
}
|
|
}
|
|
|
|
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 ? (product.packageLabel ?? "Packung") : 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)
|
|
let date = lot.bestBefore ?? "ohne MHD"
|
|
return "\(date) · \(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. Neuer Bestand: \(format(total)) \(product.articleUnitLabel)")
|
|
dismiss()
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
}
|
|
}
|
|
}
|