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:
@@ -435,19 +435,19 @@ actor APIClient {
|
||||
return try await send(request, as: StorageLocation.self)
|
||||
}
|
||||
|
||||
func renameLocation(id: Int, name: String) async throws -> StorageLocation {
|
||||
func renameLocation(id: String, name: String) async throws -> StorageLocation {
|
||||
var request = try makeRequest("/locations/\(id)", method: "PATCH")
|
||||
try jsonBody(&request, RenameRequest(name: name))
|
||||
return try await send(request, as: StorageLocation.self)
|
||||
}
|
||||
|
||||
func updateLocation(id: Int, _ payload: LocationUpdateRequest) async throws -> StorageLocation {
|
||||
func updateLocation(id: String, _ payload: LocationUpdateRequest) async throws -> StorageLocation {
|
||||
var request = try makeRequest("/locations/\(id)", method: "PATCH")
|
||||
try jsonBody(&request, payload)
|
||||
return try await send(request, as: StorageLocation.self)
|
||||
}
|
||||
|
||||
func deleteLocation(id: Int) async throws {
|
||||
func deleteLocation(id: String) async throws {
|
||||
try await sendNoContent(try makeRequest("/locations/\(id)", method: "DELETE"))
|
||||
}
|
||||
|
||||
|
||||
@@ -58,17 +58,18 @@ struct AssignScanView: View {
|
||||
}
|
||||
|
||||
private func handle(_ code: String) async {
|
||||
// Lagerort-QR (…/l/<ID>)?
|
||||
// Lagerort-QR (…/l/<Code>)? Der Code ist eine 10-stellige Zeichenkette.
|
||||
if let r = code.range(of: "/l/") {
|
||||
let idStr = String(code[r.upperBound...])
|
||||
let locCode = String(code[r.upperBound...])
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "/ "))
|
||||
if let locId = Int(idStr) {
|
||||
.uppercased()
|
||||
if !locCode.isEmpty {
|
||||
guard let it = pending else {
|
||||
error = "Erst ein Einzelstück scannen."; status = nil; return
|
||||
}
|
||||
do {
|
||||
_ = try await APIClient.shared.updateItem(id: it.id, ItemUpdateRequest(
|
||||
locationId: locId, shopId: it.shopId,
|
||||
locationId: locCode, shopId: it.shopId,
|
||||
acquiredOn: it.acquiredOn, warrantyUntil: it.warrantyUntil, note: it.note))
|
||||
status = "\(it.uid) zugeordnet. Nächstes Stück scannen."
|
||||
error = nil
|
||||
|
||||
@@ -153,10 +153,18 @@ struct CheckInFormView: View {
|
||||
/// Charge, fuer die gerade das MHD abgescannt wird.
|
||||
@State private var scanLineId: LineRef?
|
||||
@State private var unit: String = ""
|
||||
@State private var locationId: Int?
|
||||
@State private var locationId: String?
|
||||
@State private var busy = false
|
||||
@State private var error: String?
|
||||
|
||||
// Lagerort per QR (…/l/<Code>) setzen, statt in der Liste zu suchen.
|
||||
@State private var locScanShown = false
|
||||
@State private var locScanPaused = false
|
||||
@State private var locTorchOn = false
|
||||
@State private var locTorchLocked = false
|
||||
@State private var locScanError: String?
|
||||
@State private var locScanHinweis: String?
|
||||
|
||||
/// Einheiten der passenden Art plus das Gebinde des Artikels.
|
||||
private var unitOptions: [UnitOption] {
|
||||
var options = units
|
||||
@@ -189,13 +197,31 @@ struct CheckInFormView: View {
|
||||
Picker("Einheit", selection: $unit) {
|
||||
ForEach(unitOptions) { Text($0.label).tag($0.value) }
|
||||
}
|
||||
if !locations.isEmpty {
|
||||
}
|
||||
|
||||
if !locations.isEmpty {
|
||||
Section {
|
||||
Picker("Lagerort", selection: $locationId) {
|
||||
Text("– keiner –").tag(Int?.none)
|
||||
Text("– keiner –").tag(String?.none)
|
||||
ForEach(locations) { location in
|
||||
Text(location.name).tag(Int?.some(location.id))
|
||||
Text(location.name).tag(String?.some(location.id))
|
||||
}
|
||||
}
|
||||
Button {
|
||||
locScanError = nil
|
||||
locScanPaused = false
|
||||
locScanShown = true
|
||||
} label: {
|
||||
Label("Lagerort-QR scannen", systemImage: "qrcode.viewfinder")
|
||||
}
|
||||
if let locScanHinweis {
|
||||
Label(locScanHinweis, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption).foregroundStyle(.green)
|
||||
}
|
||||
} header: {
|
||||
Text("Lagerort")
|
||||
} footer: {
|
||||
Text("Den QR am Regal/Fach scannen, statt den Ort in der Liste zu suchen.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,6 +307,7 @@ struct CheckInFormView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $locScanShown) { locationScannerCover }
|
||||
.onAppear {
|
||||
// Gibt es ein Gebinde, ist das die naheliegende Eingabe.
|
||||
unit = hasPackage ? UnitOption.packageValue : product.unitName
|
||||
@@ -289,6 +316,60 @@ struct CheckInFormView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lagerort scannen
|
||||
|
||||
private var locationScannerCover: some View {
|
||||
NavigationStack {
|
||||
ScannerView(onCode: { code in Task { await handleLocationScan(code) } },
|
||||
isPaused: $locScanPaused, torchOn: $locTorchOn, torchLocked: $locTorchLocked)
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
.overlay(alignment: .bottom) {
|
||||
VStack(spacing: 6) {
|
||||
if let locScanError {
|
||||
Text(locScanError)
|
||||
.font(.callout).foregroundStyle(.white)
|
||||
.padding(.horizontal, 12).padding(.vertical, 8)
|
||||
.background(.red, in: Capsule())
|
||||
}
|
||||
Text("QR am Regal/Fach vor die Kamera halten")
|
||||
.font(.callout)
|
||||
.padding(10)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.navigationTitle("Lagerort scannen")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Abbrechen") { locScanShown = false }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
TorchButton(isOn: $locTorchOn, locked: $locTorchLocked)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { locScanPaused = false; locScanError = nil }
|
||||
}
|
||||
|
||||
/// Aus einem gescannten Lagerort-QR (…/l/<Code>) den Zielort setzen.
|
||||
private func handleLocationScan(_ code: String) async {
|
||||
guard let r = code.range(of: "/l/") else {
|
||||
locScanError = "Das ist kein Lagerort-QR."
|
||||
return
|
||||
}
|
||||
let ziel = String(code[r.upperBound...])
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "/ "))
|
||||
.uppercased()
|
||||
if let treffer = locations.first(where: { $0.id == ziel }) {
|
||||
locationId = treffer.id
|
||||
locScanHinweis = treffer.name
|
||||
locScanShown = false
|
||||
} else {
|
||||
locScanError = "Dieser Lagerort ist hier nicht bekannt."
|
||||
}
|
||||
}
|
||||
|
||||
private func format(_ value: Double) -> String {
|
||||
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ struct ItemEditView: View {
|
||||
@State private var locations: [StorageLocation] = []
|
||||
@State private var shops: [ShopItem] = []
|
||||
@State private var photo: Data?
|
||||
@State private var locationId: Int?
|
||||
@State private var locationId: String?
|
||||
@State private var shopId: Int?
|
||||
@State private var hasAcquired = false
|
||||
@State private var acquired = Date()
|
||||
@@ -138,8 +138,8 @@ struct ItemEditView: View {
|
||||
|
||||
Section {
|
||||
Picker("Lagerort", selection: $locationId) {
|
||||
Text("– ohne –").tag(Int?.none)
|
||||
ForEach(locations) { l in Text(l.name).tag(Int?.some(l.id)) }
|
||||
Text("– ohne –").tag(String?.none)
|
||||
ForEach(locations) { l in Text(l.name).tag(String?.some(l.id)) }
|
||||
}
|
||||
Picker("Gekauft bei", selection: $shopId) {
|
||||
Text("– unbekannt –").tag(Int?.none)
|
||||
@@ -406,7 +406,7 @@ struct ItemAddSheet: View {
|
||||
@State private var count = 1
|
||||
@State private var locations: [StorageLocation] = []
|
||||
@State private var shops: [ShopItem] = []
|
||||
@State private var locationId: Int?
|
||||
@State private var locationId: String?
|
||||
@State private var shopId: Int?
|
||||
@State private var hasAcquired = false
|
||||
@State private var acquired = Date()
|
||||
@@ -430,8 +430,8 @@ struct ItemAddSheet: View {
|
||||
Section {
|
||||
Stepper("Anzahl: \(count)", value: $count, in: 1...200)
|
||||
Picker("Lagerort", selection: $locationId) {
|
||||
Text("– ohne –").tag(Int?.none)
|
||||
ForEach(locations) { l in Text(l.name).tag(Int?.some(l.id)) }
|
||||
Text("– ohne –").tag(String?.none)
|
||||
ForEach(locations) { l in Text(l.name).tag(String?.some(l.id)) }
|
||||
}
|
||||
Picker("Gekauft bei", selection: $shopId) {
|
||||
Text("– unbekannt –").tag(Int?.none)
|
||||
|
||||
@@ -181,7 +181,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
|
||||
let title: String
|
||||
let singular: String
|
||||
let load: () async throws -> [Item]
|
||||
let delete: (Int) async throws -> Void
|
||||
let delete: (Item.ID) async throws -> Void
|
||||
/// Optionaler kleiner Hinweis am Zeilenende (z. B. der Kategorie-Typ).
|
||||
let badge: (Item) -> String?
|
||||
/// Nur Einträge, die das erfüllen, werden gezeigt (z. B. der Typ-Filter).
|
||||
@@ -190,7 +190,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
|
||||
@ViewBuilder let editor: (_ editing: Item?, _ all: [Item], _ done: @escaping () -> Void) -> Editor
|
||||
|
||||
@State private var items: [Item] = []
|
||||
@State private var collapsed: Set<Int> = []
|
||||
@State private var collapsed: Set<Item.ID> = []
|
||||
@State private var busy = true
|
||||
@State private var error: String?
|
||||
@State private var editing: Item?
|
||||
@@ -200,7 +200,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
|
||||
private struct Node: Identifiable {
|
||||
let item: Item
|
||||
let depth: Int
|
||||
var id: Int { item.id }
|
||||
var id: Item.ID { item.id }
|
||||
}
|
||||
|
||||
private var shown: [Item] { items.filter(include) }
|
||||
@@ -221,11 +221,11 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
|
||||
return result
|
||||
}
|
||||
|
||||
private func childCount(_ id: Int) -> Int { shown.filter { $0.parentId == id }.count }
|
||||
private func childCount(_ id: Item.ID) -> Int { shown.filter { $0.parentId == id }.count }
|
||||
|
||||
private var visible: [Node] {
|
||||
let parent = Dictionary(uniqueKeysWithValues: shown.map { ($0.id, $0.parentId) })
|
||||
func hidden(_ id: Int) -> Bool {
|
||||
func hidden(_ id: Item.ID) -> Bool {
|
||||
var p = parent[id] ?? nil
|
||||
while let cur = p {
|
||||
if collapsed.contains(cur) { return true }
|
||||
@@ -324,7 +324,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
|
||||
.padding(.leading, CGFloat(node.depth) * 16)
|
||||
}
|
||||
|
||||
private func toggle(_ id: Int) {
|
||||
private func toggle(_ id: Item.ID) {
|
||||
if collapsed.contains(id) { collapsed.remove(id) } else { collapsed.insert(id) }
|
||||
}
|
||||
|
||||
@@ -441,7 +441,7 @@ struct LocationEditor: View {
|
||||
let done: () -> Void
|
||||
|
||||
@State private var name: String
|
||||
@State private var parentId: Int?
|
||||
@State private var parentId: String?
|
||||
@State private var error: String?
|
||||
@State private var busy = false
|
||||
|
||||
@@ -467,9 +467,9 @@ struct LocationEditor: View {
|
||||
}
|
||||
Section {
|
||||
Picker("Übergeordnet", selection: $parentId) {
|
||||
Text("– oberste Ebene –").tag(Int?.none)
|
||||
Text("– oberste Ebene –").tag(String?.none)
|
||||
ForEach(parentOptions) { loc in
|
||||
Text(LocationEditor.pfad(loc, in: all)).tag(Int?.some(loc.id))
|
||||
Text(LocationEditor.pfad(loc, in: all)).tag(String?.some(loc.id))
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
@@ -513,8 +513,8 @@ struct LocationEditor: View {
|
||||
}
|
||||
|
||||
/// Alle Unterorte (rekursiv) – als Ziel beim Umhängen ausgeschlossen.
|
||||
private static func descendants(of id: Int, in all: [StorageLocation]) -> Set<Int> {
|
||||
var result: Set<Int> = []
|
||||
private static func descendants(of id: String, in all: [StorageLocation]) -> Set<String> {
|
||||
var result: Set<String> = []
|
||||
var stack = [id]
|
||||
while let cur = stack.popLast() {
|
||||
for kid in all where kid.parentId == cur {
|
||||
|
||||
@@ -134,10 +134,10 @@ struct Product: Codable, Identifiable, Hashable {
|
||||
|
||||
/// Mindestbestand eines Produkts/einer Gruppe an einem Lagerort (Anzeige).
|
||||
struct LocationMinStock: Codable, Hashable, Identifiable {
|
||||
let locationId: Int
|
||||
let locationId: String
|
||||
let locationName: String?
|
||||
let minStock: Double
|
||||
var id: Int { locationId }
|
||||
var id: String { locationId }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case minStock = "min_stock"
|
||||
@@ -148,7 +148,7 @@ struct LocationMinStock: Codable, Hashable, Identifiable {
|
||||
|
||||
/// Ein Mindestbestand-Eintrag zum Speichern (Menge in Artikeleinheiten).
|
||||
struct LocationMinStockIn: Codable {
|
||||
let locationId: Int
|
||||
let locationId: String
|
||||
let minStock: Double
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
@@ -217,7 +217,7 @@ struct Lot: Codable, Identifiable, Hashable {
|
||||
let quantity: Double
|
||||
let bestBefore: String?
|
||||
let bestBeforePrecision: String?
|
||||
let locationId: Int?
|
||||
let locationId: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, quantity
|
||||
@@ -229,9 +229,9 @@ struct Lot: Codable, Identifiable, Hashable {
|
||||
}
|
||||
|
||||
struct StorageLocation: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let id: String
|
||||
let name: String
|
||||
let parentId: Int?
|
||||
let parentId: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name
|
||||
@@ -243,7 +243,7 @@ struct CheckInLine: Codable {
|
||||
let quantity: Double
|
||||
let bestBefore: String?
|
||||
let bestBeforePrecision: String
|
||||
let locationId: Int?
|
||||
let locationId: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case quantity
|
||||
@@ -391,12 +391,12 @@ struct LocationNeedGroup: Codable, Identifiable {
|
||||
}
|
||||
|
||||
struct LocationNeeds: Codable, Identifiable {
|
||||
let locationId: Int
|
||||
let locationId: String
|
||||
let locationName: String
|
||||
let products: [LocationNeedProduct]
|
||||
let groups: [LocationNeedGroup]
|
||||
|
||||
var id: Int { locationId }
|
||||
var id: String { locationId }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case products, groups
|
||||
@@ -531,9 +531,9 @@ struct CategoryItem: Codable, Identifiable, Hashable {
|
||||
|
||||
/// Gemeinsame Form baumartiger Stammdaten (Kategorien, Lagerorte): eine flache
|
||||
/// Liste mit Eltern-Verweis, aus der sich der Baum aufbauen lässt.
|
||||
protocol TreeItem: Identifiable where ID == Int {
|
||||
protocol TreeItem: Identifiable {
|
||||
var name: String { get }
|
||||
var parentId: Int? { get }
|
||||
var parentId: ID? { get }
|
||||
}
|
||||
|
||||
extension CategoryItem: TreeItem {}
|
||||
@@ -580,7 +580,7 @@ struct ObjectCheckInRequest: Codable {
|
||||
let productId: Int
|
||||
let quantity: Double
|
||||
let unit: String
|
||||
let locationId: Int?
|
||||
let locationId: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case quantity, unit
|
||||
@@ -592,8 +592,8 @@ struct ObjectCheckInRequest: Codable {
|
||||
struct RelocateRequest: Codable {
|
||||
let productId: Int
|
||||
let quantity: Double
|
||||
let fromLocationId: Int?
|
||||
let toLocationId: Int?
|
||||
let fromLocationId: String?
|
||||
let toLocationId: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case quantity
|
||||
@@ -606,7 +606,7 @@ struct RelocateRequest: Codable {
|
||||
struct RemoveRequest: Codable {
|
||||
let productId: Int
|
||||
let quantity: Double
|
||||
let locationId: Int?
|
||||
let locationId: String?
|
||||
let reason: String
|
||||
let note: String?
|
||||
|
||||
@@ -627,7 +627,7 @@ struct RemovalStat: Codable, Identifiable, Hashable {
|
||||
struct RemovalHistoryItem: Codable, Identifiable, Hashable {
|
||||
let reason: String
|
||||
let quantity: Double
|
||||
let locationId: Int?
|
||||
let locationId: String?
|
||||
let locationName: String?
|
||||
let note: String?
|
||||
let username: String?
|
||||
@@ -681,7 +681,7 @@ struct Item: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let uid: String
|
||||
let productId: Int
|
||||
let locationId: Int?
|
||||
let locationId: String?
|
||||
let locationName: String?
|
||||
let shopId: Int?
|
||||
let shopName: String?
|
||||
@@ -757,7 +757,7 @@ struct ItemDocumentUpload: Codable {
|
||||
|
||||
struct ItemCreateRequest: Codable {
|
||||
let count: Int
|
||||
let locationId: Int?
|
||||
let locationId: String?
|
||||
let shopId: Int?
|
||||
let acquiredOn: String?
|
||||
let warrantyUntil: String?
|
||||
@@ -776,7 +776,7 @@ struct ItemCreateRequest: Codable {
|
||||
}
|
||||
|
||||
struct ItemUpdateRequest: Encodable {
|
||||
var locationId: Int?
|
||||
var locationId: String?
|
||||
var shopId: Int?
|
||||
var acquiredOn: String?
|
||||
var warrantyUntil: String?
|
||||
@@ -996,7 +996,7 @@ struct PackageType: Codable, Identifiable, Hashable {
|
||||
|
||||
struct NewLocationRequest: Codable {
|
||||
let name: String
|
||||
let parentId: Int?
|
||||
let parentId: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
@@ -1008,7 +1008,7 @@ struct NewLocationRequest: Codable {
|
||||
/// (auch `null` = oberste Ebene), damit „auf oberste Ebene holen" ankommt.
|
||||
struct LocationUpdateRequest: Codable {
|
||||
let name: String
|
||||
let parentId: Int?
|
||||
let parentId: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
|
||||
@@ -15,19 +15,19 @@ struct ObjectStockSection: View {
|
||||
|
||||
enum StockSheet: Identifiable {
|
||||
case add
|
||||
case relocate(from: Int?)
|
||||
case remove(loc: Int?)
|
||||
case relocate(from: String?)
|
||||
case remove(loc: String?)
|
||||
var id: String {
|
||||
switch self {
|
||||
case .add: return "add"
|
||||
case .relocate(let f): return "relocate-\(f.map(String.init) ?? "none")"
|
||||
case .remove(let l): return "remove-\(l.map(String.init) ?? "none")"
|
||||
case .relocate(let f): return "relocate-\(f ?? "none")"
|
||||
case .remove(let l): return "remove-\(l ?? "none")"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var einheit: String { product.unitName.isEmpty ? "Stück" : product.unitName }
|
||||
private func ortName(_ id: Int?) -> String {
|
||||
private func ortName(_ id: String?) -> String {
|
||||
guard let id else { return "Ohne Lagerort" }
|
||||
return locations.first { $0.id == id }?.name ?? "Ort \(id)"
|
||||
}
|
||||
@@ -170,12 +170,12 @@ struct ObjectFieldRow: View {
|
||||
private struct LocationPicker: View {
|
||||
let title: String
|
||||
let locations: [StorageLocation]
|
||||
@Binding var selection: Int?
|
||||
@Binding var selection: String?
|
||||
|
||||
var body: some View {
|
||||
Picker(title, selection: $selection) {
|
||||
Text("– ohne Lagerort –").tag(Int?.none)
|
||||
ForEach(locations) { loc in Text(loc.name).tag(Int?.some(loc.id)) }
|
||||
Text("– ohne Lagerort –").tag(String?.none)
|
||||
ForEach(locations) { loc in Text(loc.name).tag(String?.some(loc.id)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,7 @@ struct ObjectAddSheet: View {
|
||||
var perform: () async -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var locationId: Int?
|
||||
@State private var locationId: String?
|
||||
@State private var quantity = ""
|
||||
@State private var busy = false
|
||||
@State private var error: String?
|
||||
@@ -229,12 +229,12 @@ struct ObjectAddSheet: View {
|
||||
struct ObjectRelocateSheet: View {
|
||||
let product: Product
|
||||
let locations: [StorageLocation]
|
||||
let initialFrom: Int?
|
||||
let initialFrom: String?
|
||||
var perform: () async -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var fromId: Int?
|
||||
@State private var toId: Int?
|
||||
@State private var fromId: String?
|
||||
@State private var toId: String?
|
||||
@State private var quantity = ""
|
||||
@State private var busy = false
|
||||
@State private var error: String?
|
||||
@@ -277,11 +277,11 @@ struct ObjectRemoveSheet: View {
|
||||
let product: Product
|
||||
let locations: [StorageLocation]
|
||||
let einheit: String
|
||||
let initialLoc: Int?
|
||||
let initialLoc: String?
|
||||
var perform: () async -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var locationId: Int?
|
||||
@State private var locationId: String?
|
||||
@State private var quantity = ""
|
||||
@State private var reason = "broken"
|
||||
@State private var note = ""
|
||||
|
||||
@@ -424,7 +424,7 @@ struct ProductLocationMinView: View {
|
||||
|
||||
private struct MinRow: Identifiable {
|
||||
let id = UUID()
|
||||
var locationId: Int?
|
||||
var locationId: String?
|
||||
var amount: String
|
||||
}
|
||||
|
||||
@@ -442,8 +442,8 @@ struct ProductLocationMinView: View {
|
||||
ForEach($rows) { $row in
|
||||
HStack {
|
||||
Picker("Lagerort", selection: $row.locationId) {
|
||||
Text("– wählen –").tag(Int?.none)
|
||||
ForEach(locations) { l in Text(l.name).tag(Int?.some(l.id)) }
|
||||
Text("– wählen –").tag(String?.none)
|
||||
ForEach(locations) { l in Text(l.name).tag(String?.some(l.id)) }
|
||||
}
|
||||
TextField("Menge", text: $row.amount)
|
||||
.keyboardType(.decimalPad)
|
||||
@@ -488,7 +488,7 @@ struct ProductLocationMinView: View {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
var list: [LocationMinStockIn] = []
|
||||
var gesehen: Set<Int> = []
|
||||
var gesehen: Set<String> = []
|
||||
for row in rows {
|
||||
guard let loc = row.locationId, !gesehen.contains(loc) else { continue }
|
||||
let wert = Double(row.amount.replacingOccurrences(of: ",", with: ".")) ?? 0
|
||||
|
||||
Reference in New Issue
Block a user