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