Beim Anlegen eines Artikels fuehrte die Kategoriewahl zurueck in die
Artikelliste: Das Formular steht in einem Blatt, und die geschobene Auswahl
riss beim Zurueckspringen den ganzen Navigationsstapel mit - das Blatt also
gleich dazu. Der Artikel liess sich so nicht mehr anlegen.
Damit ist .pickerStyle(.navigationLink) an allen acht Stellen wieder
draussen. Die Auswahl ist wieder ein Menue und rueckt mit fuehrenden
Leerzeichen ein, wie vor d7002e2.
Der Umbruch langer Namen bleibt damit ungeloest: Faellt "Schublade Links
Unten" auf eine zweite Zeile, faengt die wieder ganz links an. Die Grenze
steht jetzt als Kommentar an Baumzeile.label, damit der naechste Versuch
nicht wieder in dieselbe Falle laeuft. Funktionierendes Anlegen wiegt
schwerer als saubere Einrueckung.
Der mittige Loeschdialog aus demselben Commit bleibt.
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 }
|
||
}
|
||
}
|