Gegenstands-Verwaltung (Non-Food) neben Lebensmitteln
Die Kategorie bestimmt die Verwaltungsart (food/object). Gegenstaende: Menge je Lagerort statt Chargen/MHD, Umlagern (ohne Grund) und Entfernen mit Pflicht-Grund samt Entnahme-Statistik. Beliebig viele eigene Felder je Kategorie (vererbt an Unterkategorien), "gekauft bei" ueber eine verwaltbare Shop-Liste und ein Produktlink. Barcode-Lookup zusaetzlich ueber Open Products Facts. Der Lebensmittel-Teil (Chargen/MHD/FEFO) bleibt unveraendert. Umgesetzt in Backend (FastAPI, +Tests gruen), Web (React) und iOS (SwiftUI). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
327
ios/Sources/ObjectStockView.swift
Normal file
327
ios/Sources/ObjectStockView.swift
Normal file
@@ -0,0 +1,327 @@
|
||||
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: Int?)
|
||||
case remove(loc: Int?)
|
||||
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")"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var einheit: String { product.unitName.isEmpty ? "Stück" : product.unitName }
|
||||
private func ortName(_ id: Int?) -> String {
|
||||
guard let id else { return "Ohne Lagerort" }
|
||||
return locations.first { $0.id == id }?.name ?? "Ort \(id)"
|
||||
}
|
||||
|
||||
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: Int?
|
||||
|
||||
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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: Int?
|
||||
@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: Int?
|
||||
var perform: () async -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var fromId: Int?
|
||||
@State private var toId: Int?
|
||||
@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: Int?
|
||||
var perform: () async -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var locationId: Int?
|
||||
@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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user