Beim Einlagern lief auch ein Einzelstueck-Produkt durch den Charge-Weg (CheckInFormView -> Lot). Einzelstuecke zaehlen aber Items, nicht Lots - der Bestand blieb 0. Im Einlagern-Ablauf werden Einzelstueck-Produkte jetzt ueber ItemAddSheet als physische Exemplare (UID/QR) angelegt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
328 lines
13 KiB
Swift
328 lines
13 KiB
Swift
import SwiftUI
|
||
|
||
struct CheckInView: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
// Kamera bewusst nur auf Wunsch: „Einlagern“ öffnet ein Formular, kein Sucher.
|
||
@State private var scanShown = false
|
||
@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 scannedItem: Item?
|
||
@State private var suggestion: LookupResult.Suggestion?
|
||
@State private var suggestedGroupId: Int?
|
||
@State private var suggestedGroupName: String?
|
||
@State private var suggestedCategoryId: Int?
|
||
@State private var suggestedCategoryName: String?
|
||
@State private var unknownCode: String?
|
||
@State private var manualCodeShown = false
|
||
@State private var manualCode = ""
|
||
|
||
// Manuelle Artikelsuche (statt Scanzwang).
|
||
@State private var query = ""
|
||
@State private var results: [Product] = []
|
||
@State private var searching = false
|
||
|
||
@State private var units: [Unit] = []
|
||
@State private var locations: [StorageLocation] = []
|
||
|
||
var body: some View {
|
||
Form {
|
||
if let status { Section { banner(status, color: .green) } }
|
||
if let error { Section { banner(error, color: .red) } }
|
||
|
||
Section {
|
||
Button {
|
||
error = nil; status = nil
|
||
scanShown = true
|
||
} label: {
|
||
Label("EAN scannen", systemImage: "barcode.viewfinder")
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
|
||
Button {
|
||
manualCode = ""
|
||
manualCodeShown = true
|
||
} label: {
|
||
Label("EAN von Hand eingeben", systemImage: "keyboard")
|
||
}
|
||
|
||
NavigationLink {
|
||
ProductFormView(prefillBarcode: nil, groupId: nil) { created in
|
||
product = created
|
||
}
|
||
} label: {
|
||
Label("Neuen Artikel anlegen", systemImage: "plus")
|
||
}
|
||
} header: {
|
||
Text("Neu erfassen")
|
||
} footer: {
|
||
Text("Die Kamera öffnet sich erst beim Tippen auf „EAN scannen“ – kein Zwang.")
|
||
}
|
||
|
||
if let suggestion {
|
||
suggestionSection(suggestion)
|
||
} else if let unknownCode {
|
||
unknownSection(unknownCode)
|
||
}
|
||
|
||
Section {
|
||
HStack {
|
||
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
|
||
TextField("Name oder Marke …", text: $query)
|
||
.autocorrectionDisabled()
|
||
if !query.isEmpty {
|
||
Button { query = "" } label: {
|
||
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
if searching {
|
||
HStack { ProgressView(); Text("Suche …").foregroundStyle(.secondary) }
|
||
}
|
||
ForEach(results) { p in
|
||
Button {
|
||
error = nil; status = nil
|
||
product = p
|
||
} label: {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(p.name).foregroundStyle(.primary)
|
||
let unter = [p.brand, "Bestand: \(bestandText(p))"]
|
||
.compactMap { $0 }.joined(separator: " · ")
|
||
Text(unter).font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
if !query.trimmingCharacters(in: .whitespaces).isEmpty && results.isEmpty && !searching {
|
||
Text("Kein Artikel gefunden – oben neu anlegen.")
|
||
.foregroundStyle(.secondary).font(.callout)
|
||
}
|
||
} header: {
|
||
Text("Vorhandenen Artikel wählen")
|
||
}
|
||
}
|
||
.navigationTitle("Einlagern")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
|
||
}
|
||
.task {
|
||
units = (try? await APIClient.shared.units()) ?? []
|
||
locations = (try? await APIClient.shared.locations()) ?? []
|
||
}
|
||
.task(id: query) { await search() }
|
||
.fullScreenCover(isPresented: $scanShown) { scannerCover }
|
||
.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) { }
|
||
}
|
||
.sheet(item: $product) { item in
|
||
NavigationStack {
|
||
if item.isIndividual {
|
||
// Einzelstücke werden nicht als Charge eingelagert, sondern als
|
||
// physische Exemplare (UID/QR) angelegt – die zählen den Bestand.
|
||
ItemAddSheet(productId: item.id, onDone: {
|
||
status = "\(item.name): Einzelstück(e) angelegt."
|
||
})
|
||
} else {
|
||
CheckInFormView(product: item, units: units, locations: locations) { message in
|
||
status = message
|
||
product = nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.sheet(item: $scannedItem) { it in
|
||
NavigationStack { ItemEditView(item: it) }
|
||
}
|
||
}
|
||
|
||
// MARK: - Scanner (nur auf Wunsch)
|
||
|
||
private var scannerCover: some View {
|
||
NavigationStack {
|
||
ScannerView(onCode: { code in Task { await resolve(code) } },
|
||
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
|
||
.ignoresSafeArea(edges: .bottom)
|
||
.overlay(alignment: .bottom) {
|
||
Text("Barcode vor die Kamera halten")
|
||
.font(.callout)
|
||
.padding(10)
|
||
.background(.ultraThinMaterial, in: Capsule())
|
||
.padding(.bottom, 24)
|
||
}
|
||
.navigationTitle("EAN scannen")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) {
|
||
Button("Abbrechen") { scanShown = false }
|
||
}
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
TorchButton(isOn: $torchOn, locked: $torchLocked)
|
||
}
|
||
}
|
||
}
|
||
.onAppear { paused = false }
|
||
}
|
||
|
||
// MARK: - Bausteine
|
||
|
||
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))
|
||
.listRowInsets(EdgeInsets())
|
||
.listRowBackground(Color.clear)
|
||
}
|
||
|
||
private func bestandText(_ p: Product) -> String {
|
||
let wert = p.stockInArticleUnits
|
||
let zahl = wert == wert.rounded() ? String(Int(wert)) : String(format: "%.2f", wert)
|
||
return "\(zahl) \(p.articleUnitLabel)"
|
||
}
|
||
|
||
/// Sagt vor dem Anlegen, welche Gruppe der Artikel bekommt und warum.
|
||
@ViewBuilder
|
||
private func groupNote() -> some View {
|
||
if let kategorie = suggestedCategoryName {
|
||
noteRow(symbol: "square.grid.2x2",
|
||
title: "Wird der Kategorie „\(kategorie)“ zugeordnet",
|
||
detail: "Aus der Produkt-Datenbank vorgeschlagen – im Formular änderbar.")
|
||
}
|
||
if let name = suggestedGroupName {
|
||
noteRow(symbol: "tag",
|
||
title: "Wird der Gruppe „\(name)“ zugeordnet",
|
||
detail: "Dieser EAN-Code ist dort hinterlegt.")
|
||
}
|
||
}
|
||
|
||
private func noteRow(symbol: String, title: String, detail: String) -> some View {
|
||
HStack(alignment: .top, spacing: 6) {
|
||
Image(systemName: symbol)
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text(title).font(.caption).bold()
|
||
Text(detail).font(.caption2).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func suggestionSection(_ item: LookupResult.Suggestion) -> some View {
|
||
Section {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text(item.name).font(.headline)
|
||
Text([item.brand, item.quantityText].compactMap { $0 }.joined(separator: " · "))
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
Text("In einer Produkt-Datenbank gefunden, noch nicht im Katalog.")
|
||
.font(.caption2).foregroundStyle(.secondary)
|
||
groupNote()
|
||
NavigationLink {
|
||
ProductFormView(prefillBarcode: item.barcode, groupId: suggestedGroupId,
|
||
categoryId: suggestedCategoryId, suggestion: item) { created in
|
||
suggestion = nil
|
||
product = created
|
||
}
|
||
} label: {
|
||
Label("Anlegen & einlagern", systemImage: "plus.circle.fill")
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
} header: {
|
||
Text("Vorschlag zum Scan")
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func unknownSection(_ code: String) -> some View {
|
||
Section {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("Unbekannter Code \(code)").font(.headline)
|
||
Text("Weder im Katalog noch in Open Food / Products Facts.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
groupNote()
|
||
NavigationLink {
|
||
ProductFormView(prefillBarcode: code, groupId: suggestedGroupId,
|
||
categoryId: suggestedCategoryId) { created in
|
||
unknownCode = nil
|
||
product = created
|
||
}
|
||
} label: {
|
||
Label("Artikel anlegen", systemImage: "plus")
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
} header: {
|
||
Text("Zum Scan")
|
||
}
|
||
}
|
||
|
||
// MARK: - Logik
|
||
|
||
private func search() async {
|
||
let q = query.trimmingCharacters(in: .whitespaces)
|
||
guard q.count >= 2 else { results = []; searching = false; return }
|
||
// Kurze Verzögerung, damit nicht bei jedem Tastendruck abgefragt wird.
|
||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||
if Task.isCancelled { return }
|
||
searching = true
|
||
defer { searching = false }
|
||
results = (try? await APIClient.shared.searchProducts(q)) ?? []
|
||
}
|
||
|
||
private func resolve(_ code: String) async {
|
||
paused = true
|
||
error = nil
|
||
status = nil
|
||
suggestion = nil
|
||
unknownCode = nil
|
||
do {
|
||
// Einzelstück-QR (…/i/<UID>) → direkt das Stück öffnen statt Barcode-Suche.
|
||
if let r = code.range(of: "/i/") {
|
||
let uid = String(code[r.upperBound...])
|
||
.trimmingCharacters(in: CharacterSet(charactersIn: "/ "))
|
||
.uppercased()
|
||
if !uid.isEmpty, let item = try? await APIClient.shared.itemByUid(uid: uid) {
|
||
scannedItem = item
|
||
scanShown = false
|
||
return
|
||
}
|
||
}
|
||
let result = try await APIClient.shared.lookup(barcode: code)
|
||
suggestedGroupId = result.groupId
|
||
suggestedGroupName = result.groupName
|
||
suggestedCategoryId = result.categoryId
|
||
suggestedCategoryName = result.categoryName
|
||
if let existing = result.existingProduct {
|
||
product = existing
|
||
} else if let hint = result.suggestion {
|
||
suggestion = hint
|
||
} else {
|
||
unknownCode = code
|
||
}
|
||
scanShown = false
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
scanShown = false
|
||
}
|
||
}
|
||
}
|