Nachschlagen: Lagerort-QR zeigt alle Artikel an diesem Ort

Neuer Endpunkt GET /location/{code} liefert alle Artikel an einem Lagerort inkl. der Unterorte (Lot-Bestaende und Einzelstuecke), sortiert nach Name mit Menge in Artikeleinheiten. In der App loest der /l/-QR beim Nachschlagen jetzt diese Liste auf; ein Tipp auf einen Eintrag oeffnet den Artikel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-27 13:10:29 +02:00
parent 4607e3a6a8
commit 21ab0a825a
6 changed files with 258 additions and 5 deletions

View File

@@ -104,6 +104,11 @@ actor APIClient {
try await send(try makeRequest("/products/\(id)"), as: Product.self)
}
/// Alles, was an einem Lagerort (inkl. Unterorte) liegt Ziel des /l/-QR beim Nachschlagen.
func locationContents(code: String) async throws -> LocationContents {
try await send(try makeRequest("/location/\(code)"), as: LocationContents.self)
}
func lookup(barcode: String) async throws -> LookupResult {
let escaped = barcode.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
return try await send(try makeRequest("/products/lookup?barcode=\(escaped)"), as: LookupResult.self)

View File

@@ -1,7 +1,7 @@
import SwiftUI
/// Nur ansehen: Barcode Artikel, Einzelstück-QR genau dieses Stück.
/// Kein Ein-/Auslagern zum schnellen Nachschauen (z.B. wann gekauft, Garantie).
/// 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
@@ -12,6 +12,7 @@ struct LookupScanView: View {
@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 {
@@ -22,8 +23,9 @@ struct LookupScanView: View {
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
Text("Barcode oder Einzelstück-QR scannen nur ansehen")
Text("Barcode, Einzelstück- oder Lagerort-QR scannen nur ansehen")
.font(.caption).foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if let error {
Text(error).font(.callout).foregroundStyle(.red)
@@ -46,6 +48,10 @@ struct LookupScanView: View {
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 {
@@ -60,6 +66,20 @@ struct LookupScanView: View {
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)
@@ -75,3 +95,69 @@ struct LookupScanView: View {
}
}
}
/// 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 }
}
}
}

View File

@@ -405,6 +405,39 @@ struct LocationNeeds: Codable, Identifiable {
}
}
/// Ein Artikel mit seinem Bestand an einem Lagerort (in Artikeleinheiten).
struct LocationContentEntry: Codable, Identifiable, Hashable {
let productId: Int
let name: String
let brand: String?
let stock: Double
let unitLabel: String
let individual: Bool
var id: Int { productId }
enum CodingKeys: String, CodingKey {
case name, brand, stock, individual
case productId = "product_id"
case unitLabel = "unit_label"
}
}
/// Alles, was an einem gescannten Lagerort liegt inklusive der Unterorte.
struct LocationContents: Codable, Identifiable {
let locationId: String
let locationName: String
let products: [LocationContentEntry]
var id: String { locationId }
enum CodingKeys: String, CodingKey {
case products
case locationId = "location_id"
case locationName = "location_name"
}
}
struct ExpiringItem: Codable, Identifiable {
let lotId: Int
let productId: Int