Bisher stand dort nur "Einzelstück R9D55SDC". Die UID sagt aber nichts darueber, welches Stueck das ist - gerade bei Kleidung, wo das NFC-Tag eingenaeht und unsichtbar ist. Unter dem Sucher lag dafuer die halbe Seite ungenutzt. Die Karte zeigt jetzt Vorschaubild, Artikelname, Marke und die Notiz, darunter als beschriftete Zeilen die Einzelstuecknummer und den aktuellen Lagerort. Der Ort ist beim Zuordnen die nuetzlichste Angabe ueberhaupt: man sieht sofort, ob das Stueck schon irgendwo liegt. Der Block war mit 50 Zeilen zu gross fuer den ohnehin langen body und steht jetzt als EinzelstueckKarte fuer sich. ProductThumb bekommt dafuer einen Groessenparameter (Vorgabe 34 wie bisher, also keine Aenderung an den Listen) und bei grossen Bildern einen passenderen Eckenradius und ein groesseres Ersatzsymbol. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
225 lines
9.2 KiB
Swift
225 lines
9.2 KiB
Swift
import SwiftUI
|
||
|
||
/// Zuordnen per Scan: erst ein Einzelstück (…/i/<UID>) scannen, dann einen
|
||
/// Lagerort (…/l/<ID>) – das Stück wird dem Ort zugewiesen. Danach gleich das
|
||
/// nächste Stück, ohne Tippen.
|
||
struct AssignScanView: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
@State private var paused = false
|
||
@State private var torchOn = false
|
||
@State private var torchLocked = false
|
||
@State private var pending: Item? // gescanntes/gewähltes Stück, wartet auf Ort
|
||
@State private var status: String?
|
||
@State private var error: String?
|
||
@State private var showPicker = false
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(spacing: 14) {
|
||
ScannerView(onCode: { code in Task { await handle(code) } },
|
||
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
|
||
.aspectRatio(4.0 / 3.0, contentMode: .fit)
|
||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||
.padding(.horizontal)
|
||
|
||
if let pending {
|
||
EinzelstueckKarte(item: pending) { self.pending = nil }
|
||
} else {
|
||
VStack(spacing: 8) {
|
||
Text("Schritt 1: Einzelstück-QR scannen – oder ohne Code:")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
// Kleidung trägt keinen QR, aber ein eingenähtes NFC-Tag.
|
||
NFCLesenButton(titel: "Einzelstück-Tag lesen") { uid in
|
||
Task {
|
||
let gefunden = await waehle(uid: uid)
|
||
if !gefunden {
|
||
error = "Kein Einzelstück mit der UID \(uid)."
|
||
status = nil
|
||
}
|
||
}
|
||
} onFehler: { meldung in
|
||
error = meldung; status = nil
|
||
}
|
||
// Für Stücke ohne Code jeder Art: aus der Liste wählen.
|
||
Button { showPicker = true } label: {
|
||
Label("Einzelstück aus Liste wählen", systemImage: "list.bullet")
|
||
}
|
||
}
|
||
}
|
||
|
||
if let status {
|
||
Text(status).font(.callout).foregroundStyle(.green)
|
||
.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal)
|
||
}
|
||
if let error {
|
||
Text(error).font(.callout).foregroundStyle(.red)
|
||
.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal)
|
||
}
|
||
}
|
||
.padding(.vertical)
|
||
}
|
||
.navigationTitle("Ort zuordnen")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
|
||
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) }
|
||
}
|
||
.sheet(isPresented: $showPicker) {
|
||
ItemPickerSheet { it in pending = it; status = nil; error = nil }
|
||
}
|
||
}
|
||
|
||
private func handle(_ code: String) async {
|
||
// Lagerort-QR (…/l/<Code>)? Der Code ist eine 10-stellige Zeichenkette.
|
||
if let r = code.range(of: "/l/") {
|
||
let locCode = String(code[r.upperBound...])
|
||
.trimmingCharacters(in: CharacterSet(charactersIn: "/ "))
|
||
.uppercased()
|
||
if !locCode.isEmpty {
|
||
guard let it = pending else {
|
||
error = "Erst ein Einzelstück scannen."; status = nil; return
|
||
}
|
||
do {
|
||
// Alle Felder mitsenden: ItemUpdateRequest schreibt auch null
|
||
// mit, damit sich Felder leeren lassen. Kaufpreis und Waehrung
|
||
// fehlten hier – ein Zuordnen per Scan hat sie stillschweigend
|
||
// geloescht.
|
||
_ = try await APIClient.shared.updateItem(id: it.id, ItemUpdateRequest(
|
||
locationId: locCode, shopId: it.shopId,
|
||
acquiredOn: it.acquiredOn, warrantyUntil: it.warrantyUntil, note: it.note,
|
||
priceCents: it.priceCents, currency: it.currency))
|
||
status = "\(it.uid) zugeordnet. Nächstes Stück scannen."
|
||
error = nil
|
||
pending = nil
|
||
} catch { self.error = error.localizedDescription }
|
||
return
|
||
}
|
||
}
|
||
// Einzelstück-QR (…/i/<UID>)?
|
||
if let uid = einzelstueckCode(aus: code), await waehle(uid: uid) {
|
||
return
|
||
}
|
||
error = "Unbekannter Code – bitte einen Einzelstück- oder Lagerort-QR scannen."
|
||
}
|
||
|
||
/// Einzelstück als „wartet auf Lagerort" vormerken – aus Scan, NFC oder Liste.
|
||
/// Gibt zurueck, ob es gefunden wurde; der Aufrufer entscheidet ueber die
|
||
/// Fehlermeldung, weil ein Scan noch andere Deutungen zulaesst als ein Tag.
|
||
@discardableResult
|
||
private func waehle(uid: String) async -> Bool {
|
||
guard let it = try? await APIClient.shared.itemByUid(uid: uid) else { return false }
|
||
pending = it
|
||
status = nil
|
||
error = nil
|
||
return true
|
||
}
|
||
}
|
||
|
||
/// Was gerade in der Hand liegt und auf einen Lagerort wartet.
|
||
///
|
||
/// Die UID allein sagt nichts darueber, was man da haelt - gerade bei Kleidung,
|
||
/// wo das NFC-Tag eingenaeht und unsichtbar ist. Unter dem Sucher ist reichlich
|
||
/// Platz, also zeigt die Karte Bild, Name, Marke und den aktuellen Ort.
|
||
private struct EinzelstueckKarte: View {
|
||
let item: Item
|
||
var onAbbrechen: () -> Void
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
HStack(alignment: .top, spacing: 12) {
|
||
ProductThumb(productId: item.productId, groesse: 64)
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(item.productName ?? "Unbekannter Artikel")
|
||
.font(.headline)
|
||
if let marke = item.productBrand, !marke.isEmpty {
|
||
Text(marke).font(.subheadline).foregroundStyle(.secondary)
|
||
}
|
||
if let notiz = item.note, !notiz.isEmpty {
|
||
Text(notiz).font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
||
}
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
|
||
Divider()
|
||
|
||
zeile("Einzelstücknummer") {
|
||
Text(item.uid).font(.callout.monospaced())
|
||
}
|
||
zeile("Derzeit") {
|
||
Text(item.locationName ?? "ohne Lagerort").multilineTextAlignment(.trailing)
|
||
}
|
||
|
||
Divider()
|
||
|
||
HStack {
|
||
Text("Jetzt den Lagerort-QR scannen")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
Spacer()
|
||
Button("Abbrechen", action: onAbbrechen).font(.caption)
|
||
}
|
||
}
|
||
.padding().frame(maxWidth: .infinity)
|
||
.background(Color.marke.opacity(0.15))
|
||
.clipShape(RoundedRectangle(cornerRadius: 12)).padding(.horizontal)
|
||
}
|
||
|
||
private func zeile<Inhalt: View>(
|
||
_ titel: String, @ViewBuilder inhalt: () -> Inhalt
|
||
) -> some View {
|
||
HStack {
|
||
Text(titel).foregroundStyle(.secondary)
|
||
Spacer()
|
||
inhalt()
|
||
}
|
||
.font(.callout)
|
||
}
|
||
}
|
||
|
||
/// Einzelstück aus einer durchsuchbaren Liste wählen – für Stücke ohne eigenen QR.
|
||
struct ItemPickerSheet: View {
|
||
var onPick: (Item) -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var items: [Item] = []
|
||
@State private var locations: [StorageLocation] = []
|
||
@State private var query = ""
|
||
@State private var busy = true
|
||
|
||
private var gefiltert: [Item] {
|
||
let q = query.trimmingCharacters(in: .whitespaces).lowercased()
|
||
guard !q.isEmpty else { return items }
|
||
return items.filter {
|
||
[$0.productName, $0.uid, $0.productBrand, $0.note]
|
||
.compactMap { $0 }.joined(separator: " ").lowercased().contains(q)
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
List {
|
||
if busy && items.isEmpty { ProgressView() }
|
||
ForEach(gefiltert) { it in
|
||
Button { onPick(it); dismiss() } label: {
|
||
ItemRow(item: it, locations: locations)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
if !busy && gefiltert.isEmpty {
|
||
Text("Nichts gefunden.").foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.searchable(text: $query, prompt: "Produkt, UID oder Notiz")
|
||
.navigationTitle("Einzelstück wählen")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
|
||
.task {
|
||
items = (try? await APIClient.shared.allItems()) ?? []
|
||
locations = (try? await APIClient.shared.locations()) ?? []
|
||
busy = false
|
||
}
|
||
}
|
||
}
|
||
}
|