Files
Vorrania/ios/Sources/CheckInView.swift
Scarriffle d518b4aa23 Lagerorte per 10-Zeichen-Code statt fortlaufender ID; iOS-Einlagern ohne Kamerazwang
Lagerort-IDs sind jetzt ein zufaelliger 10-Zeichen-Code (wie die Einzelstueck-UIDs) statt einer fortlaufenden Zahl - so kollidiert die Stammdaten-Sicherung zwischen zwei Instanzen praktisch nie mehr, und der Code ist zugleich der Inhalt des QR /l/<code>. Alle Fremdschluessel (lots, movements, items, Mindestbestaende, parent_id) ziehen mit; die Umstellung laeuft einmalig und transaktional beim Serverstart (_migrate_locations_to_code) und rollt bei Fehlern komplett zurueck. Vor dem Deploy ein DB-Backup machen.

iOS-Einlagern oeffnet nicht mehr sofort die Kamera, sondern ein Formular mit Artikelsuche; die Kamera kommt erst per Button. Im Formular laesst sich der Lagerort zusaetzlich per /l/-QR scannen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:15:25 +02:00

320 lines
12 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 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 {
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
}
}
}