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>
329 lines
12 KiB
Swift
329 lines
12 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.
|
||
/// 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 }
|
||
}
|
||
}
|