Files
Vorrania/ios/Sources/ObjectStockView.swift
Scarriffle 1db2d6389c iOS: Lagerort-Picker zeigen den vollen Pfad statt nur den Namen
Wie im Web: alle Lagerort-Auswahlen (Einlagern, Einzelstueck/Item, Objekt-Bestand hinzufuegen/umlagern/entfernen, Mindestbestand je Ort) zeigen 'Hedingen -> Keller -> Schublade 1' statt nur 'Schublade 1', damit gleichnamige Orte unterscheidbar sind. Neuer Helfer StorageLocation.path(in:) bzw. locationPath(_:in:) in Models.swift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 13:27:02 +02:00

329 lines
12 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
/// 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?
var body: some View {
Form {
Section {
LocationPicker(title: "Lagerort", locations: locations, selection: $locationId)
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() } } }
}
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?
var body: some View {
Form {
Section {
LocationPicker(title: "Von", locations: locations, selection: $fromId)
LocationPicker(title: "Nach", locations: locations, selection: $toId)
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() } } }
.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?
var body: some View {
Form {
Section {
LocationPicker(title: "Lagerort", locations: locations, selection: $locationId)
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() } } }
.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 }
}
}