Files
Vorrania/ios/Sources/CheckInView.swift
T
ScarriffleandClaude Opus 5 ea7a42ac22 App: Tag beim Anlegen, Menge beim Anlegen, Loeschen und NFC beim Ein-/Auslagern
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>
2026-10-15 20:55:37 +02:00

797 lines
33 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
import Vision
import PhotosUI
import UIKit
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")
}
// Eingenaehte Tags an Kleidung lassen sich nicht scannen. Ein
// Tag benennt immer genau ein Einzelstueck, deshalb fuehrt er
// dorthin und nicht in die Artikelsuche.
NFCLesenButton(titel: "NFC-Tag lesen") { uid in
Task {
error = nil; status = nil
if let found = try? await APIClient.shared.itemByUid(uid: uid) {
scannedItem = found
} else {
error = "Kein Einzelstück mit der UID \(uid)."
}
}
} onFehler: { meldung in
error = meldung
}
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 = "Eingelagert\n\(item.name): Einzelstück(e) angelegt."
})
} else if item.foodLike {
// Lebensmittel + Verbrauchsgegenstand: Charge mit Menge/MHD.
CheckInFormView(product: item, units: units, locations: locations) { message in
status = message
product = nil
}
} else {
// Menge je Lagerort (Gegenstand): ohne MHD, direkt Menge + Lagerort.
ObjectAddSheet(product: item, locations: locations,
einheit: item.unitName.isEmpty ? "Stück" : item.unitName,
perform: { status = "Eingelagert\n\(item.name): Menge hinzugefügt." })
}
}
}
.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 {
// Erste Zeile = Titel (fett), Rest = Detail – ergibt eine saubere,
// rundum eingerückte Melde-Pille statt eines randlosen Balkens.
let zeilen = text.components(separatedBy: "\n")
return VStack(alignment: .leading, spacing: 2) {
Text(zeilen.first ?? text).font(.headline)
if zeilen.count > 1 {
Text(zeilen.dropFirst().joined(separator: "\n")).font(.subheadline)
}
}
.padding(.horizontal, 16).padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundStyle(.white)
.background(color, in: RoundedRectangle(cornerRadius: 14))
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
.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 uid = einzelstueckCode(aus: code),
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
}
}
}
// MARK: - Kassenzettel scannen (nur Lebensmittel)
private extension UIImage {
/// Orientierung auf `.up` normalisieren, damit `cgImage`-Pixel und `size`-Punkte
/// zusammenpassen (Kamerafotos sind sonst gedreht).
func nachObenGedreht() -> UIImage {
guard imageOrientation != .up else { return self }
let format = UIGraphicsImageRendererFormat.default()
format.scale = scale
return UIGraphicsImageRenderer(size: size, format: format).image { _ in
draw(in: CGRect(origin: .zero, size: size))
}
}
/// Auf ein Rechteck in Punkt-Koordinaten (size) zuschneiden.
func zugeschnitten(auf rect: CGRect) -> UIImage? {
let px = CGRect(x: rect.minX * scale, y: rect.minY * scale,
width: rect.width * scale, height: rect.height * scale)
guard let cg = cgImage?.cropping(to: px.integral) else { return nil }
return UIImage(cgImage: cg, scale: scale, orientation: .up)
}
}
/// OCR (on-device) auf einem Ausschnitt – deutsche Texterkennung, zeilenweise von
/// oben nach unten. Das Bild verlässt das Gerät nicht; nur die Textzeilen gehen zum
/// Server für den Abgleich.
private func ocrZeilen(_ cg: CGImage) async -> [String] {
await withCheckedContinuation { fortsetzung in
DispatchQueue.global(qos: .userInitiated).async {
let anfrage = VNRecognizeTextRequest()
anfrage.recognitionLevel = .accurate
anfrage.usesLanguageCorrection = true
anfrage.recognitionLanguages = ["de-DE"]
try? VNImageRequestHandler(cgImage: cg, options: [:]).perform([anfrage])
let treffer = (anfrage.results as? [VNRecognizedTextObservation]) ?? []
let vonOben = treffer.sorted { $0.boundingBox.maxY > $1.boundingBox.maxY }
fortsetzung.resume(returning: vonOben.compactMap { $0.topCandidates(1).first?.string })
}
}
}
/// Offensichtliche Nicht-Artikel (Preise, Summen, Kopf/Fuß) grob aussortieren.
private func plausibleArtikelZeilen(_ zeilen: [String]) -> [String] {
let muell = ["summe", "gesamt", "zwischensumme", "mwst", "ust", "eur", "bar",
"rueckgeld", "rückgeld", "kartenzahlung", "betrag", "total",
"kassenbon", "beleg", "datum", "uhr", "filiale", "kunde", "steuer"]
return zeilen.compactMap { roh in
let s = roh.trimmingCharacters(in: .whitespacesAndNewlines)
guard s.filter({ $0.isLetter }).count >= 3 else { return nil }
let klein = s.lowercased()
return muell.contains(where: { klein.contains($0) }) ? nil : s
}
}
/// Menge aus einer Zeile lesen („2x", „2 Stk", führende Zahl), sonst 1.
private func mengeAusZeile(_ zeile: String) -> Double {
for muster in ["(\\d+)\\s*[xX]", "(\\d+)\\s*[sS][tT]", "^\\s*(\\d+)\\b"] {
if let r = zeile.range(of: muster, options: .regularExpression) {
let ziffern = zeile[r].filter { $0.isNumber }
if let n = Int(ziffern), n > 0, n < 100 { return Double(n) }
}
}
return 1
}
/// Einstieg: Foto → Bereich markieren → Zeilen prüfen → einlagern.
struct KassenzettelView: View {
@Environment(\.dismiss) private var dismiss
@State private var bild: UIImage?
@State private var zeilen: [MatchLine]?
@State private var analysiere = false
@State private var kameraAn = false
@State private var galerie: PhotosPickerItem?
@State private var locations: [StorageLocation] = []
var body: some View {
NavigationStack {
inhalt
.navigationTitle("Kassenzettel")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Schließen") { dismiss() } } }
.task { locations = (try? await APIClient.shared.locations()) ?? [] }
.fullScreenCover(isPresented: $kameraAn) {
CameraPicker(isPresented: $kameraAn) { img in bild = img.nachObenGedreht(); zeilen = nil }
.ignoresSafeArea()
}
.onChange(of: galerie) { item in
guard let item else { return }
Task {
if let data = try? await item.loadTransferable(type: Data.self),
let ui = UIImage(data: data) { bild = ui.nachObenGedreht(); zeilen = nil }
galerie = nil
}
}
}
}
@ViewBuilder private var inhalt: some View {
if analysiere {
VStack(spacing: 12) { ProgressView(); Text("Lese Artikel…").foregroundStyle(.secondary) }
} else if let zeilen {
KassenzettelPruefenView(match: zeilen, locations: locations) { dismiss() }
} else if let bild {
KassenzettelMarkierenView(bild: bild, onWeiter: starteErkennung) { self.bild = nil }
} else {
start
}
}
private var start: some View {
VStack(spacing: 16) {
Spacer()
Image(systemName: "doc.text.viewfinder").font(.system(size: 52)).foregroundStyle(.secondary)
Text("Kassenzettel fotografieren, den Artikelbereich markieren – die App liest die Zeilen und schlägt Lebensmittel vor.")
.multilineTextAlignment(.center).foregroundStyle(.secondary).padding(.horizontal)
Button { kameraAn = true } label: {
Label("Foto aufnehmen", systemImage: "camera").frame(maxWidth: .infinity)
}.buttonStyle(.borderedProminent)
PhotosPicker(selection: $galerie, matching: .images) {
Label("Aus Galerie wählen", systemImage: "photo").frame(maxWidth: .infinity)
}.buttonStyle(.bordered)
Spacer()
}.padding()
}
private func starteErkennung(_ ausschnitt: UIImage) {
analysiere = true
Task {
let erg = await erkenne(ausschnitt)
await MainActor.run { zeilen = erg; analysiere = false }
}
}
private func erkenne(_ img: UIImage) async -> [MatchLine] {
guard let cg = img.cgImage else { return [] }
let roh = await ocrZeilen(cg)
let zeilen = plausibleArtikelZeilen(roh)
guard !zeilen.isEmpty else { return [] }
return (try? await APIClient.shared.matchReceipt(zeilen))
?? zeilen.map { MatchLine(text: $0, candidates: []) }
}
}
/// Rechteck über den Artikelblock ziehen; liefert den zugeschnittenen Bildausschnitt.
struct KassenzettelMarkierenView: View {
let bild: UIImage
var onWeiter: (UIImage) -> Void
var onNeu: () -> Void
@State private var rect: CGRect = .zero
@State private var startRect: CGRect?
@State private var fitRect: CGRect = .zero
var body: some View {
VStack(spacing: 12) {
Text("Ziehe das Rechteck über die Artikel. Am Griff unten rechts vergrößern.")
.font(.callout).foregroundStyle(.secondary)
.multilineTextAlignment(.center).padding(.horizontal)
GeometryReader { geo in
ZStack(alignment: .topLeading) {
Image(uiImage: bild).resizable().scaledToFit()
.frame(width: geo.size.width, height: geo.size.height)
Rectangle().stroke(Color.marke, lineWidth: 2)
.background(Color.marke.opacity(0.15))
.frame(width: rect.width, height: rect.height)
.offset(x: rect.minX, y: rect.minY)
.gesture(zieh(resize: false))
Rectangle().fill(Color.marke)
.frame(width: 26, height: 26)
.offset(x: rect.maxX - 13, y: rect.maxY - 13)
.gesture(zieh(resize: true))
}
.onAppear { setzeFit(geo.size) }
.onChange(of: geo.size) { setzeFit($0) }
}
HStack {
Button("Neues Foto", action: onNeu)
Spacer()
Button("Weiter") { weiter() }.buttonStyle(.borderedProminent)
}.padding(.horizontal)
}.padding(.vertical)
}
private func setzeFit(_ container: CGSize) {
let iw = bild.size.width, ih = bild.size.height
guard iw > 0, ih > 0, container.width > 0 else { return }
let s = min(container.width / iw, container.height / ih)
let w = iw * s, h = ih * s
fitRect = CGRect(x: (container.width - w) / 2, y: (container.height - h) / 2, width: w, height: h)
if rect == .zero { rect = fitRect.insetBy(dx: fitRect.width * 0.08, dy: fitRect.height * 0.12) }
}
private func zieh(resize: Bool) -> some Gesture {
DragGesture()
.onChanged { v in
if startRect == nil { startRect = rect }
guard let s = startRect else { return }
var r = s
if resize {
r.size.width = max(48, s.width + v.translation.width)
r.size.height = max(48, s.height + v.translation.height)
} else {
r.origin.x = s.origin.x + v.translation.width
r.origin.y = s.origin.y + v.translation.height
}
rect = klemme(r)
}
.onEnded { _ in startRect = nil }
}
private func klemme(_ r: CGRect) -> CGRect {
guard fitRect.width > 0 else { return r }
var o = r
o.size.width = min(o.size.width, fitRect.width)
o.size.height = min(o.size.height, fitRect.height)
o.origin.x = min(max(o.origin.x, fitRect.minX), fitRect.maxX - o.width)
o.origin.y = min(max(o.origin.y, fitRect.minY), fitRect.maxY - o.height)
return o
}
private func weiter() {
guard fitRect.width > 0 else { onWeiter(bild); return }
let s = bild.size.width / fitRect.width // Bildschirm-Einheiten → Bildpunkte
let crop = CGRect(x: (rect.minX - fitRect.minX) * s, y: (rect.minY - fitRect.minY) * s,
width: rect.width * s, height: rect.height * s)
onWeiter(bild.zugeschnitten(auf: crop) ?? bild)
}
}
private struct KassenzettelArtikel: Hashable { let id: Int; let name: String; let unit: String }
private struct KassenzettelZeile: Identifiable {
let id = UUID()
let text: String
let kandidaten: [MatchCandidate]
var artikel: KassenzettelArtikel?
var menge: Double
var ortId: String?
}
private struct ZeilenRef: Identifiable { let id: UUID }
/// Prüfen-Liste: je Zeile Artikel (Auto-Treffer + eigene Suche), Menge, Lagerort.
struct KassenzettelPruefenView: View {
let match: [MatchLine]
let locations: [StorageLocation]
var onFertig: () -> Void
@State private var zeilen: [KassenzettelZeile] = []
@State private var sucheZiel: ZeilenRef?
@State private var scanZiel: ZeilenRef?
@State private var busy = false
@State private var meldung: String?
private var zugeordnet: Int { zeilen.filter { $0.artikel != nil }.count }
var body: some View {
Group {
if zeilen.isEmpty {
VStack(spacing: 10) {
Image(systemName: "doc.text.magnifyingglass").font(.system(size: 40)).foregroundStyle(.secondary)
Text("Keine Artikel erkannt.").font(.headline)
Text("Versuch es mit einem engeren Rechteck oder besserem Licht.")
.font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
}.padding()
} else {
List {
ForEach($zeilen) { $z in
Section {
zeile($z)
} header: {
Text(z.text)
.textCase(nil).font(.footnote).foregroundStyle(.secondary)
}
}
Section {
Button { Task { await einlagern() } } label: {
HStack {
if busy { ProgressView().padding(.trailing, 4) }
Text("\(zugeordnet) Artikel einlagern")
}
}.disabled(busy || zugeordnet == 0)
} footer: {
Text("Nicht zugeordnete Zeilen werden übersprungen. Neue Artikel werden nicht angelegt.")
}
}
.listStyle(.insetGrouped)
}
}
.onAppear(perform: aufbauen)
.sheet(item: $sucheZiel) { ref in
NavigationStack {
ArtikelSucheSheet { produkt in setzeArtikel(ref.id, produkt: produkt); sucheZiel = nil }
}
}
.sheet(item: $scanZiel) { ref in
LocationScannerView(locations: locations) { loc in setzeOrt(ref.id, loc.id); scanZiel = nil }
}
.alert("Fertig", isPresented: Binding(get: { meldung != nil },
set: { if !$0 { meldung = nil; onFertig() } })) {
Button("OK") { }
} message: { Text(meldung ?? "") }
}
/// Drei Zeilen (Rows) je Artikel: Auswahl, Menge, Lagerort. In einer eigenen
/// Section gruppiert – so ist klar, was zusammengehört, und nichts ist gequetscht.
@ViewBuilder private func zeile(_ z: Binding<KassenzettelZeile>) -> some View {
let zeile = z.wrappedValue
// Artikel wählen
Menu {
ForEach(zeile.kandidaten) { k in
Button("\(k.name) · \(k.score) %") {
z.wrappedValue.artikel = KassenzettelArtikel(id: k.productId, name: k.name, unit: k.checkInUnit)
}
}
Divider()
Button { sucheZiel = ZeilenRef(id: zeile.id) } label: { Label("Suchen…", systemImage: "magnifyingglass") }
if zeile.artikel != nil {
Button(role: .destructive) { z.wrappedValue.artikel = nil } label: {
Label("Nicht zuordnen", systemImage: "xmark")
}
}
} label: {
HStack {
Image(systemName: zeile.artikel == nil ? "questionmark.circle" : "checkmark.circle.fill")
.foregroundStyle(zeile.artikel == nil ? Color.secondary : Color.green)
Text(zeile.artikel?.name ?? "– Artikel wählen –")
.foregroundStyle(zeile.artikel == nil ? Color.secondary : Color.primary)
Spacer()
Image(systemName: "chevron.up.chevron.down").font(.caption2).foregroundStyle(.secondary)
}
}
// Menge
Stepper("Menge: \(Int(zeile.menge))", value: z.menge, in: 1...99)
// Lagerort + QR-Scan
HStack {
Menu {
Button("– ohne –") { z.wrappedValue.ortId = nil }
// Voller Pfad statt nur des Namens: In dieser engen Zeile gibt es
// keine Einrueckung, und "Fach A" allein ist nicht eindeutig.
ForEach(locations) { loc in
Button(loc.path(in: locations)) { z.wrappedValue.ortId = loc.id }
}
} label: {
Label(ortName(zeile.ortId), systemImage: "mappin.and.ellipse")
}
Spacer()
Button { scanZiel = ZeilenRef(id: zeile.id) } label: {
Label("QR", systemImage: "qrcode.viewfinder")
}.buttonStyle(.borderless)
}
}
private func ortName(_ id: String?) -> String {
guard let id else { return "ohne Ort" }
return locations.first { $0.id == id }?.name ?? "Ort"
}
private func aufbauen() {
guard zeilen.isEmpty else { return }
zeilen = match.map { m in
KassenzettelZeile(
text: m.text, kandidaten: m.candidates,
artikel: m.candidates.first.map {
KassenzettelArtikel(id: $0.productId, name: $0.name, unit: $0.checkInUnit)
},
menge: mengeAusZeile(m.text), ortId: nil,
)
}
}
private func setzeArtikel(_ id: UUID, produkt: Product) {
guard let i = zeilen.firstIndex(where: { $0.id == id }) else { return }
let unit = (produkt.packageSize ?? 0) > 0 ? "package" : produkt.baseUnit
zeilen[i].artikel = KassenzettelArtikel(id: produkt.id, name: produkt.name, unit: unit)
}
private func setzeOrt(_ id: UUID, _ ortId: String) {
guard let i = zeilen.firstIndex(where: { $0.id == id }) else { return }
zeilen[i].ortId = ortId
}
private func einlagern() async {
busy = true
var ein = 0, weg = 0
var fehler: String?
for z in zeilen {
guard let a = z.artikel else { weg += 1; continue }
let payload = BatchCheckInRequest(
productId: a.id, unit: a.unit,
lines: [CheckInLine(quantity: z.menge, bestBefore: nil,
bestBeforePrecision: "day", locationId: z.ortId)],
)
do { _ = try await APIClient.shared.checkInBatch(payload); ein += 1 }
catch { fehler = error.localizedDescription }
}
busy = false
meldung = "\(ein) eingelagert, \(weg) übersprungen."
+ (fehler.map { " Fehler: \($0)" } ?? "")
}
}
/// Freie Artikelsuche (nur Lebensmittel) als Rückfall, wenn kein Auto-Treffer passt.
struct ArtikelSucheSheet: View {
var onPick: (Product) -> Void
@Environment(\.dismiss) private var dismiss
@State private var text = ""
@State private var treffer: [Product] = []
@State private var suchlauf = UUID()
var body: some View {
List(treffer) { p in
Button {
onPick(p); dismiss()
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(p.name).foregroundStyle(.primary)
if let b = p.brand, !b.isEmpty {
Text(b).font(.caption).foregroundStyle(.secondary)
}
}
}
}
.listStyle(.plain)
.searchable(text: $text, prompt: "Lebensmittel suchen")
.navigationTitle("Artikel suchen")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
.onChange(of: text) { q in
let lauf = UUID(); suchlauf = lauf
Task {
try? await Task.sleep(nanoseconds: 250_000_000)
if suchlauf != lauf { return }
let liste = (try? await APIClient.shared.searchProducts(q)) ?? []
let food = liste.filter { $0.tracking != "object" }
await MainActor.run { if suchlauf == lauf { treffer = Array(food.prefix(25)) } }
}
}
}
}