- Lagerort per QR scannen in Menge-hinzufuegen/entfernen/umlagern (Ziel), nicht nur ueber das Dropdown suchen. - Beim Einlagern laufen "Menge je Lagerort"-Gegenstaende jetzt ueber die Mengen- Eingabe (ObjectAddSheet) statt durch den Charge-Weg mit MHD - Socken & Co. fragen kein MHD mehr ab. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
353 lines
13 KiB
Swift
353 lines
13 KiB
Swift
import SwiftUI
|
||
|
||
/// Bestand eines Gegenstands je Lagerort – das Gegenstück zur Chargen-Liste der
|
||
/// Lebensmittel. Erlaubt Hinzufügen, Umlagern (ohne Grund) und Entfernen (mit
|
||
/// Pflicht-Grund) und zeigt eine kleine Entnahme-Statistik. Wird als Abschnitt
|
||
/// in die Produkt-Detailansicht eingebettet.
|
||
struct ObjectStockSection: View {
|
||
let product: Product
|
||
let locations: [StorageLocation]
|
||
let lots: [Lot]
|
||
var onChanged: () async -> Void
|
||
|
||
@State private var removals: RemovalSummary?
|
||
@State private var sheet: StockSheet?
|
||
|
||
enum StockSheet: Identifiable {
|
||
case add
|
||
case relocate(from: String?)
|
||
case remove(loc: String?)
|
||
var id: String {
|
||
switch self {
|
||
case .add: return "add"
|
||
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: String?) -> String {
|
||
guard let id else { return "Ohne Lagerort" }
|
||
let pfad = locationPath(id, in: locations)
|
||
return pfad.isEmpty ? "Ort \(id)" : pfad
|
||
}
|
||
|
||
var body: some View {
|
||
Group {
|
||
Section("Bestand je Lagerort") {
|
||
ForEach(lots) { lot in
|
||
HStack {
|
||
Text(ortName(lot.locationId))
|
||
Spacer()
|
||
Text("\(formatAmount(lot.quantity)) \(einheit)")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
if lots.isEmpty {
|
||
Text("Noch kein Bestand.").foregroundStyle(.secondary)
|
||
}
|
||
HStack {
|
||
Button { sheet = .add } label: { Label("Hinzufügen", systemImage: "plus") }
|
||
Spacer()
|
||
Button { sheet = .relocate(from: lots.first?.locationId) } label: {
|
||
Label("Umlagern", systemImage: "arrow.left.arrow.right")
|
||
}
|
||
.disabled(lots.isEmpty)
|
||
Spacer()
|
||
Button(role: .destructive) { sheet = .remove(loc: lots.first?.locationId) } label: {
|
||
Label("Entfernen", systemImage: "trash")
|
||
}
|
||
.disabled(lots.isEmpty)
|
||
}
|
||
.buttonStyle(.borderless)
|
||
.font(.callout)
|
||
}
|
||
|
||
if let removals, !removals.stats.isEmpty {
|
||
Section("Bereits entnommen") {
|
||
ForEach(removals.stats) { s in
|
||
LabeledContent(RemovalReasons.label(s.reason),
|
||
value: "\(formatAmount(s.quantity)) (\(s.count)×)")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.sheet(item: $sheet) { welche in
|
||
NavigationStack {
|
||
switch welche {
|
||
case .add:
|
||
ObjectAddSheet(product: product, locations: locations, einheit: einheit,
|
||
perform: { await afterAction() })
|
||
case .relocate(let from):
|
||
ObjectRelocateSheet(product: product, locations: locations, initialFrom: from,
|
||
perform: { await afterAction() })
|
||
case .remove(let loc):
|
||
ObjectRemoveSheet(product: product, locations: locations, einheit: einheit,
|
||
initialLoc: loc, perform: { await afterAction() })
|
||
}
|
||
}
|
||
}
|
||
.task(id: product.id) { await loadRemovals() }
|
||
.onChange(of: lots) { _ in Task { await loadRemovals() } }
|
||
}
|
||
|
||
private func loadRemovals() async {
|
||
removals = try? await APIClient.shared.productRemovals(id: product.id)
|
||
}
|
||
|
||
private func afterAction() async {
|
||
await onChanged()
|
||
await loadRemovals()
|
||
}
|
||
}
|
||
|
||
/// Eine Eingabe für ein selbst definiertes Feld, passend zum Feldtyp.
|
||
struct ObjectFieldRow: View {
|
||
let def: FieldDefinition
|
||
@Binding var value: String
|
||
|
||
private var titel: String { def.label + (def.required ? " *" : "") }
|
||
|
||
private var dateBinding: Binding<Date> {
|
||
Binding(
|
||
get: {
|
||
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"
|
||
return f.date(from: value) ?? Date()
|
||
},
|
||
set: {
|
||
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"
|
||
value = f.string(from: $0)
|
||
}
|
||
)
|
||
}
|
||
private var boolBinding: Binding<Bool> {
|
||
Binding(get: { value == "true" }, set: { value = $0 ? "true" : "false" })
|
||
}
|
||
|
||
var body: some View {
|
||
switch def.fieldType {
|
||
case "textarea":
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(titel).font(.caption).foregroundStyle(.secondary)
|
||
TextEditor(text: $value).frame(minHeight: 60)
|
||
}
|
||
case "number":
|
||
HStack {
|
||
Text(titel)
|
||
Spacer()
|
||
TextField("", text: $value)
|
||
.keyboardType(.decimalPad)
|
||
.multilineTextAlignment(.trailing)
|
||
.frame(maxWidth: 120)
|
||
if let u = def.unit, !u.isEmpty { Text(u).foregroundStyle(.secondary) }
|
||
}
|
||
case "date":
|
||
HStack {
|
||
DatePicker(titel, selection: dateBinding, displayedComponents: .date)
|
||
if !value.isEmpty {
|
||
Button { value = "" } label: { Image(systemName: "xmark.circle.fill") }
|
||
.buttonStyle(.borderless).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
case "select":
|
||
Picker(titel, selection: $value) {
|
||
Text("– keine –").tag("")
|
||
ForEach(def.options, id: \.self) { Text($0).tag($0) }
|
||
}
|
||
case "boolean":
|
||
Toggle(titel, isOn: boolBinding)
|
||
default:
|
||
HStack {
|
||
Text(titel)
|
||
Spacer()
|
||
TextField("", text: $value).multilineTextAlignment(.trailing)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Auswahl eines Lagerorts (oder „ohne").
|
||
private struct LocationPicker: View {
|
||
let title: String
|
||
let locations: [StorageLocation]
|
||
@Binding var selection: String?
|
||
|
||
var body: some View {
|
||
Picker(title, selection: $selection) {
|
||
Text("– ohne Lagerort –").tag(String?.none)
|
||
ForEach(locations) { loc in Text(loc.path(in: locations)).tag(String?.some(loc.id)) }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Menge an einem Lagerort hinzufügen.
|
||
struct ObjectAddSheet: View {
|
||
let product: Product
|
||
let locations: [StorageLocation]
|
||
let einheit: String
|
||
var perform: () async -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var locationId: String?
|
||
@State private var quantity = ""
|
||
@State private var busy = false
|
||
@State private var error: String?
|
||
@State private var locScanShown = false
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
LocationPicker(title: "Lagerort", locations: locations, selection: $locationId)
|
||
Button { locScanShown = true } label: {
|
||
Label("Lagerort scannen", systemImage: "qrcode.viewfinder")
|
||
}
|
||
QuantityField(label: "Menge", text: $quantity, suffix: einheit)
|
||
}
|
||
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
|
||
Section {
|
||
Button(busy ? "Speichern…" : "Hinzufügen") { Task { await save() } }
|
||
.disabled(busy)
|
||
}
|
||
}
|
||
.navigationTitle("Menge hinzufügen")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
|
||
.fullScreenCover(isPresented: $locScanShown) {
|
||
LocationScannerView(locations: locations) { loc in locationId = loc.id }
|
||
.ignoresSafeArea()
|
||
}
|
||
}
|
||
|
||
private func save() async {
|
||
guard let menge = Double(quantity.replacingOccurrences(of: ",", with: ".")), menge > 0 else {
|
||
error = "Bitte eine Menge größer 0 angeben."; return
|
||
}
|
||
busy = true; defer { busy = false }
|
||
do {
|
||
_ = try await APIClient.shared.objectCheckIn(
|
||
ObjectCheckInRequest(productId: product.id, quantity: menge,
|
||
unit: einheit, locationId: locationId))
|
||
await perform()
|
||
dismiss()
|
||
} catch { self.error = error.localizedDescription }
|
||
}
|
||
}
|
||
|
||
/// Menge von einem Lagerort zum anderen umbuchen.
|
||
struct ObjectRelocateSheet: View {
|
||
let product: Product
|
||
let locations: [StorageLocation]
|
||
let initialFrom: String?
|
||
var perform: () async -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var fromId: String?
|
||
@State private var toId: String?
|
||
@State private var quantity = ""
|
||
@State private var busy = false
|
||
@State private var error: String?
|
||
@State private var locScanShown = false
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
LocationPicker(title: "Von", locations: locations, selection: $fromId)
|
||
LocationPicker(title: "Nach", locations: locations, selection: $toId)
|
||
Button { locScanShown = true } label: {
|
||
Label("Ziel-Lagerort scannen", systemImage: "qrcode.viewfinder")
|
||
}
|
||
QuantityField(label: "Menge", text: $quantity, suffix: product.unitName)
|
||
}
|
||
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
|
||
Section {
|
||
Button(busy ? "Umlagern…" : "Umlagern") { Task { await save() } }.disabled(busy)
|
||
}
|
||
}
|
||
.navigationTitle("Umlagern")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
|
||
.fullScreenCover(isPresented: $locScanShown) {
|
||
LocationScannerView(locations: locations) { loc in toId = loc.id }
|
||
.ignoresSafeArea()
|
||
}
|
||
.onAppear { fromId = initialFrom }
|
||
}
|
||
|
||
private func save() async {
|
||
guard let menge = Double(quantity.replacingOccurrences(of: ",", with: ".")), menge > 0 else {
|
||
error = "Bitte eine Menge größer 0 angeben."; return
|
||
}
|
||
busy = true; defer { busy = false }
|
||
do {
|
||
_ = try await APIClient.shared.relocate(
|
||
RelocateRequest(productId: product.id, quantity: menge,
|
||
fromLocationId: fromId, toLocationId: toId))
|
||
await perform()
|
||
dismiss()
|
||
} catch { self.error = error.localizedDescription }
|
||
}
|
||
}
|
||
|
||
/// Menge mit Pflicht-Grund aus dem Bestand entfernen.
|
||
struct ObjectRemoveSheet: View {
|
||
let product: Product
|
||
let locations: [StorageLocation]
|
||
let einheit: String
|
||
let initialLoc: String?
|
||
var perform: () async -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var locationId: String?
|
||
@State private var quantity = ""
|
||
@State private var reason = "broken"
|
||
@State private var note = ""
|
||
@State private var busy = false
|
||
@State private var error: String?
|
||
@State private var locScanShown = false
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
LocationPicker(title: "Lagerort", locations: locations, selection: $locationId)
|
||
Button { locScanShown = true } label: {
|
||
Label("Lagerort scannen", systemImage: "qrcode.viewfinder")
|
||
}
|
||
QuantityField(label: "Menge", text: $quantity, suffix: einheit)
|
||
Picker("Grund", selection: $reason) {
|
||
ForEach(RemovalReasons.all, id: \.value) { Text($0.label).tag($0.value) }
|
||
}
|
||
LabeledField(label: "Notiz", text: $note)
|
||
}
|
||
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
|
||
Section {
|
||
Button(busy ? "Entfernen…" : "Entfernen") { Task { await save() } }
|
||
.disabled(busy)
|
||
.foregroundStyle(.red)
|
||
}
|
||
}
|
||
.navigationTitle("Aus Bestand entfernen")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
|
||
.fullScreenCover(isPresented: $locScanShown) {
|
||
LocationScannerView(locations: locations) { loc in locationId = loc.id }
|
||
.ignoresSafeArea()
|
||
}
|
||
.onAppear { locationId = initialLoc }
|
||
}
|
||
|
||
private func save() async {
|
||
guard let menge = Double(quantity.replacingOccurrences(of: ",", with: ".")), menge > 0 else {
|
||
error = "Bitte eine Menge größer 0 angeben."; return
|
||
}
|
||
busy = true; defer { busy = false }
|
||
do {
|
||
_ = try await APIClient.shared.removeStock(
|
||
RemoveRequest(productId: product.id, quantity: menge, locationId: locationId,
|
||
reason: reason, note: note.isEmpty ? nil : note))
|
||
await perform()
|
||
dismiss()
|
||
} catch { self.error = error.localizedDescription }
|
||
}
|
||
}
|