Files
Vorrania/ios/Sources/ObjectStockView.swift
T
ScarriffleandClaude Opus 5 501d80a9b8 Lagerort- und Kategorieauswahl zeigen den Baum statt einer Mischung
Die Auswahl war nicht flach, weil die Beschriftung gefehlt haette - der volle
Pfad stand laengst drin. Sie kam vom Server nach dem BLATTnamen sortiert, und
so standen "Garage -> Fach A" und "Keller -> Fach A" irgendwo zwischen allem
anderen. Geschwister und Kinder fanden nie zusammen.

Neu ist baum() in Baum.swift: bringt eine flache Eltern-Kind-Liste in
Baumreihenfolge und rueckt je Ebene ein. Waisen gelten als oberste Ebene,
damit nichts lautlos aus der Auswahl faellt.

Derselbe Lauf lag bisher dreimal im Projekt (CategoryPicker, TreeMasterView,
LocationEditor). Alle drei zeigen jetzt auf den einen - unterm Strich
verschwinden mehr Zeilen als dazukommen. Zwei der Kopien waren auch nicht
gleichwertig: LocationEditor.pfad hatte den Zyklusschutz von path(in:) nicht
und waere bei einer ringfoermigen Elternkette endlos gelaufen.

Umgestellt sind neun Auswahlfelder. Die Lagerortzeile beim Kassenzettel zeigte
nur den nackten Namen und bekommt den vollen Pfad - dort ist kein Platz zum
Einruecken.

MARKETING_VERSION stand auf 1.2 in der project.pbxproj, aber auf 1.1 in der
project.yml, aus der sie erzeugt wird. Die Quelle nachgezogen, sonst haette
das naechste "xcodegen generate" die Anhebung zurueckgedreht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 20:11:25 +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.
/// Welches Mengen-Sheet gerade offen ist. Wird auf der stabilen Form-Ebene der
/// Detailansicht präsentiert (nicht im Abschnitt selbst), sonst schloss sich das
/// Fenster beim ersten Öffnen sofort wieder (das spät fertige Laden der
/// Entnahme-Statistik hat das frische Sheet weggerissen).
enum ObjectStockSheet: 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")"
}
}
}
struct ObjectStockSection: View {
let product: Product
let locations: [StorageLocation]
let lots: [Lot]
let removals: RemovalSummary?
@Binding var sheet: ObjectStockSheet?
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)×)")
}
}
}
}
}
}
/// 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(baum(locations)) { e in Text(e.label).tag(String?.some(e.item.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 }
}
}