Vier Bruchstellen im taeglichen Gebrauch, alle an derselben Wurzel: Nach dem Anlegen endete der Weg, und was danach kam, musste man sich selbst suchen. NFC beim Anlegen: createItems liefert die angelegten Stuecke samt UID laengst zurueck, ItemAddSheet warf sie nur weg. Bei genau EINEM Stueck geht es jetzt direkt zum Tag-Blatt. Bei mehreren bleibt es wie bisher - die App koennte nicht wissen, welches der fuenf Stuecke gerade am Tag haengt. Ohne NFC im Geraet (iPad) ebenso unveraendert. Menge beim Anlegen: Ein Gegenstand, der nur als Menge je Lagerort gezaehlt wird, landete nach dem Anlegen im Nichts. Das Blatt dafuer gab es schon (ObjectAddSheet), nur fuehrte kein Weg dorthin. Jetzt derselbe Uebergang wie bei Einzelstuecken, an beiden Einstiegen. Es holt sich die Lagerorte selbst - ObjectAddSheet nimmt sie als Konstante und laedt sie nicht nach. Loeschen: Der Knopf sitzt unten in der Artikelansicht, nur fuer Admins - die Route verlangt das, ohne Riegel gaebe es nur ein wortkarges 403. Lehnt der Server ab (noch Einzelstuecke oder Bestand), bleibt die Ansicht stehen und seine Begruendung steht unveraendert in der Fehlerzeile. NFC beim Ein- und Auslagern: Ein Tag benennt immer genau ein Einzelstueck, deshalb fuehrt er zur Stueck-Ansicht - beim Auslagern ist "Entfernen (mit Grund)" genau die passende Aktion. Bewusst NICHT ueber resolve() abgekuerzt: das faellt bei unbekannter UID auf lookup(barcode:) durch und boete an, einen Artikel mit einer URL als Barcode anzulegen. Beim Nachschlagen gab es NFC schon, nur unscheinbar unter der Kamerakarte - Knopf abgesetzt und in der Bildunterschrift erwaehnt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
179 lines
6.8 KiB
Swift
179 lines
6.8 KiB
Swift
import SwiftUI
|
||
|
||
/// Nur ansehen: Barcode → Artikel, Einzelstück-QR → genau dieses Stück,
|
||
/// Lagerort-QR → Liste aller Artikel an diesem Ort. Kein Ein-/Auslagern.
|
||
struct LookupScanView: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
@EnvironmentObject private var display: DisplaySettings
|
||
|
||
@State private var paused = false
|
||
@State private var torchOn = false
|
||
@State private var torchLocked = false
|
||
@State private var error: String?
|
||
@State private var item: Item?
|
||
@State private var product: Product?
|
||
@State private var contents: LocationContents?
|
||
|
||
var body: some View {
|
||
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, Einzelstück- oder Lagerort-QR scannen – oder unten "
|
||
+ "einen NFC-Tag lesen. Nur ansehen.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
|
||
// Eingenähte Tags an Kleidung lassen sich nicht scannen.
|
||
NFCLesenButton { uid in
|
||
Task {
|
||
paused = true
|
||
if let found = try? await APIClient.shared.itemByUid(uid: uid) {
|
||
item = found
|
||
} else {
|
||
error = "Kein Einzelstück mit der UID \(uid)."
|
||
paused = false
|
||
}
|
||
}
|
||
} onFehler: { meldung in
|
||
error = meldung
|
||
}
|
||
// Unauffaellig unter der Kamerakarte uebersieht man ihn sonst.
|
||
.buttonStyle(.bordered)
|
||
|
||
if let error {
|
||
Text(error).font(.callout).foregroundStyle(.red)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.horizontal)
|
||
}
|
||
}
|
||
.padding(.vertical)
|
||
}
|
||
.navigationTitle("Nachschlagen")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
|
||
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) }
|
||
}
|
||
.sheet(item: $item, onDismiss: { paused = false }) { it in
|
||
NavigationStack { ItemEditView(item: it) }
|
||
}
|
||
.sheet(item: $product, onDismiss: { paused = false }) { p in
|
||
NavigationStack { ProductDetailView(product: p) }
|
||
.environmentObject(display)
|
||
}
|
||
.sheet(item: $contents, onDismiss: { paused = false }) { c in
|
||
NavigationStack { LocationContentsView(contents: c) }
|
||
.environmentObject(display)
|
||
}
|
||
}
|
||
|
||
private func resolve(_ code: String) async {
|
||
paused = true
|
||
error = nil
|
||
// Einzelstück-QR (…/i/<UID>)?
|
||
if let uid = einzelstueckCode(aus: code),
|
||
let found = try? await APIClient.shared.itemByUid(uid: uid) {
|
||
item = found
|
||
return
|
||
}
|
||
// Lagerort-QR (…/l/<Code>)? → alles zeigen, was dort (inkl. Unterorte) liegt.
|
||
if let r = code.range(of: "/l/") {
|
||
let loc = String(code[r.upperBound...])
|
||
.trimmingCharacters(in: CharacterSet(charactersIn: "/ ")).uppercased()
|
||
if !loc.isEmpty {
|
||
do {
|
||
contents = try await APIClient.shared.locationContents(code: loc)
|
||
} catch {
|
||
self.error = "Lagerort nicht gefunden: \(loc)"
|
||
paused = false
|
||
}
|
||
return
|
||
}
|
||
}
|
||
// Sonst als Barcode auflösen.
|
||
do {
|
||
let result = try await APIClient.shared.lookup(barcode: code)
|
||
if let existing = result.existingProduct {
|
||
product = existing
|
||
} else {
|
||
error = "Nicht im Katalog: \(code)"
|
||
paused = false
|
||
}
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
paused = false
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Liste aller Artikel eines gescannten Lagerorts (inkl. Unterorte).
|
||
struct LocationContentsView: View {
|
||
let contents: LocationContents
|
||
@EnvironmentObject private var display: DisplaySettings
|
||
|
||
var body: some View {
|
||
List {
|
||
if contents.products.isEmpty {
|
||
Text("Hier ist nichts eingelagert.").foregroundStyle(.secondary)
|
||
} else {
|
||
Section {
|
||
ForEach(contents.products) { e in
|
||
NavigationLink {
|
||
ProductLoaderView(productId: e.productId).environmentObject(display)
|
||
} label: {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(e.name)
|
||
if let brand = e.brand, !brand.isEmpty {
|
||
Text(brand).font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
Spacer()
|
||
Text("\(mengeText(e.stock)) \(e.unitLabel)")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
} footer: {
|
||
Text("Bestand inkl. der Unterorte. Tippen öffnet den Artikel.")
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle(contents.locationName)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
}
|
||
|
||
private func mengeText(_ v: Double) -> String {
|
||
v == v.rounded() ? String(Int(v)) : String(format: "%.2f", v)
|
||
}
|
||
}
|
||
|
||
/// Lädt einen Artikel per ID nach und zeigt dann seine Detailansicht.
|
||
struct ProductLoaderView: View {
|
||
let productId: Int
|
||
@EnvironmentObject private var display: DisplaySettings
|
||
@State private var product: Product?
|
||
@State private var error: String?
|
||
|
||
var body: some View {
|
||
Group {
|
||
if let product {
|
||
ProductDetailView(product: product).environmentObject(display)
|
||
} else if let error {
|
||
Text(error).foregroundStyle(.red).padding()
|
||
} else {
|
||
ProgressView()
|
||
}
|
||
}
|
||
.task {
|
||
do { product = try await APIClient.shared.product(id: productId) }
|
||
catch { self.error = error.localizedDescription }
|
||
}
|
||
}
|
||
}
|