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>
This commit is contained in:
@@ -3,9 +3,12 @@ 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?
|
||||
|
||||
@@ -19,68 +22,103 @@ struct CheckInView: View {
|
||||
@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 {
|
||||
// Kamera bewusst nur als Feld oben statt bildschirmfuellend - der
|
||||
// Sucher ueber die ganze Flaeche wirkte erschlagend.
|
||||
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)
|
||||
Form {
|
||||
if let status { Section { banner(status, color: .green) } }
|
||||
if let error { Section { banner(error, color: .red) } }
|
||||
|
||||
Text("Barcode vor die Kamera halten")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Section {
|
||||
Button {
|
||||
error = nil; status = nil
|
||||
scanShown = true
|
||||
} label: {
|
||||
Label("EAN scannen", systemImage: "barcode.viewfinder")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
if let status { banner(status, color: .green) }
|
||||
if let error { banner(error, color: .red) }
|
||||
|
||||
if let suggestion {
|
||||
suggestionCard(suggestion)
|
||||
} else if let unknownCode {
|
||||
unknownCard(unknownCode)
|
||||
Button {
|
||||
manualCode = ""
|
||||
manualCodeShown = true
|
||||
} label: {
|
||||
Label("EAN von Hand eingeben", systemImage: "keyboard")
|
||||
}
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Button {
|
||||
paused = true
|
||||
manualCodeShown = true
|
||||
} label: {
|
||||
Label("EAN manuell", systemImage: "keyboard")
|
||||
.frame(maxWidth: .infinity)
|
||||
NavigationLink {
|
||||
ProductFormView(prefillBarcode: nil, groupId: nil) { created in
|
||||
product = created
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
NavigationLink {
|
||||
ProductFormView(prefillBarcode: nil, groupId: nil) { created in
|
||||
product = created
|
||||
}
|
||||
} label: {
|
||||
Label("Artikel anlegen", systemImage: "plus")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
} label: {
|
||||
Label("Neuen Artikel anlegen", systemImage: "plus")
|
||||
}
|
||||
.padding(.horizontal)
|
||||
} 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")
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
.navigationTitle("Einlagern")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Fertig") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) }
|
||||
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)
|
||||
@@ -89,22 +127,49 @@ struct CheckInView: View {
|
||||
manualCode = ""
|
||||
Task { await resolve(code) }
|
||||
}
|
||||
Button("Abbrechen", role: .cancel) { paused = false }
|
||||
Button("Abbrechen", role: .cancel) { }
|
||||
}
|
||||
.sheet(item: $product) { item in
|
||||
NavigationStack {
|
||||
CheckInFormView(product: item, units: units, locations: locations) { message in
|
||||
status = message
|
||||
product = nil
|
||||
paused = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $scannedItem, onDismiss: { paused = false }) { it in
|
||||
.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 {
|
||||
@@ -115,7 +180,14 @@ struct CheckInView: View {
|
||||
.background(color.opacity(0.9))
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.padding(.horizontal)
|
||||
.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.
|
||||
@@ -141,64 +213,73 @@ struct CheckInView: View {
|
||||
Text(detail).font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.accentColor.opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private func suggestionCard(_ item: LookupResult.Suggestion) -> some View {
|
||||
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
|
||||
@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)
|
||||
}
|
||||
} label: {
|
||||
Label("Anlegen & einlagern", systemImage: "plus.circle.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
} header: {
|
||||
Text("Vorschlag zum Scan")
|
||||
}
|
||||
.padding()
|
||||
.background(.thinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
private func unknownCard(_ code: String) -> some View {
|
||||
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
|
||||
@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)
|
||||
}
|
||||
} label: {
|
||||
Label("Artikel anlegen", systemImage: "plus")
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
} header: {
|
||||
Text("Zum Scan")
|
||||
}
|
||||
.padding()
|
||||
.background(.thinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -213,6 +294,7 @@ struct CheckInView: View {
|
||||
.uppercased()
|
||||
if !uid.isEmpty, let item = try? await APIClient.shared.itemByUid(uid: uid) {
|
||||
scannedItem = item
|
||||
scanShown = false
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -228,9 +310,10 @@ struct CheckInView: View {
|
||||
} else {
|
||||
unknownCode = code
|
||||
}
|
||||
scanShown = false
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
paused = false
|
||||
scanShown = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user