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:
Scarriffle
2026-07-25 15:33:56 +02:00
parent 580afcc133
commit 5b952524d7
29 changed files with 3116 additions and 115 deletions

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