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:
Scarriffle
2026-07-27 12:15:25 +02:00
parent 6e48cd0a7a
commit d518b4aa23
23 changed files with 507 additions and 220 deletions

View File

@@ -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"))
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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
}
}
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 = ""

View File

@@ -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