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:
@@ -188,6 +188,43 @@ actor APIClient {
|
||||
try check(response, data: data)
|
||||
}
|
||||
|
||||
// MARK: - Gegenstaende (Non-Food)
|
||||
|
||||
func shops() async throws -> [ShopItem] {
|
||||
try await send(try makeRequest("/shops"), as: [ShopItem].self)
|
||||
}
|
||||
|
||||
/// Effektive (vererbte) Felder einer Kategorie – fuer das Artikelformular.
|
||||
func categoryFields(categoryId: Int) async throws -> [FieldDefinition] {
|
||||
try await send(try makeRequest("/categories/\(categoryId)/fields"), as: [FieldDefinition].self)
|
||||
}
|
||||
|
||||
/// Menge eines Gegenstands an einem Lagerort erhoehen.
|
||||
func objectCheckIn(_ payload: ObjectCheckInRequest) async throws -> StockResponse {
|
||||
var request = try makeRequest("/stock/checkin", method: "POST")
|
||||
try jsonBody(&request, payload)
|
||||
return try await send(request, as: StockResponse.self)
|
||||
}
|
||||
|
||||
/// Menge von einem Lagerort zum anderen umbuchen (ohne Grund).
|
||||
func relocate(_ payload: RelocateRequest) async throws -> StockResponse {
|
||||
var request = try makeRequest("/stock/relocate", method: "POST")
|
||||
try jsonBody(&request, payload)
|
||||
return try await send(request, as: StockResponse.self)
|
||||
}
|
||||
|
||||
/// Menge mit Pflicht-Grund aus dem Bestand entfernen.
|
||||
func removeStock(_ payload: RemoveRequest) async throws -> StockResponse {
|
||||
var request = try makeRequest("/stock/remove", method: "POST")
|
||||
try jsonBody(&request, payload)
|
||||
return try await send(request, as: StockResponse.self)
|
||||
}
|
||||
|
||||
/// Entnahme-Statistik (Summe je Grund) und die juengsten Entnahmen.
|
||||
func productRemovals(id: Int) async throws -> RemovalSummary {
|
||||
try await send(try makeRequest("/products/\(id)/removals"), as: RemovalSummary.self)
|
||||
}
|
||||
|
||||
// MARK: - Uebersicht und Verlauf
|
||||
|
||||
func dashboardStats() async throws -> DashboardStats {
|
||||
|
||||
@@ -66,9 +66,17 @@ struct Product: Codable, Identifiable, Hashable {
|
||||
/// Mindestbestand in der erfassten Einheit, fuer die Anzeige.
|
||||
let minStockDisplay: Double?
|
||||
let minStockUnitLabel: String?
|
||||
/// Verwaltungsart aus der Kategorie: "food" (Chargen+MHD) oder "object" (Menge je Ort).
|
||||
let tracking: String?
|
||||
/// Nur fuer Gegenstaende: Bezugsquelle und Onlineshop-Link.
|
||||
let shopId: Int?
|
||||
let shopName: String?
|
||||
let productUrl: String?
|
||||
/// Selbst definierte Feldwerte: {feld_id (als Text): Wert}.
|
||||
let fieldValues: [String: String?]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, barcode, name, brand, stock, kind, barcodes
|
||||
case id, barcode, name, brand, stock, kind, barcodes, tracking
|
||||
case datePrecision = "date_precision"
|
||||
case categoryId = "category_id"
|
||||
case categoryName = "category_name"
|
||||
@@ -83,8 +91,15 @@ struct Product: Codable, Identifiable, Hashable {
|
||||
case expiredCount = "expired_count"
|
||||
case unitName = "unit_name"
|
||||
case unitFactor = "unit_factor"
|
||||
case shopId = "shop_id"
|
||||
case shopName = "shop_name"
|
||||
case productUrl = "product_url"
|
||||
case fieldValues = "field_values"
|
||||
}
|
||||
|
||||
/// Gegenstand (Menge je Lagerort) statt Lebensmittel (Chargen/MHD)?
|
||||
var isObject: Bool { tracking == "object" }
|
||||
|
||||
/// Bezeichnung der Artikeleinheit (Gebinde, sonst Produkteinheit).
|
||||
var articleUnitLabel: String {
|
||||
if let size = packageSize, size > 0 { return packageLabel ?? "Packung" }
|
||||
@@ -353,6 +368,10 @@ struct ProductUpdateRequest: Encodable {
|
||||
var datePrecision: String?
|
||||
var groupId: Int?
|
||||
var categoryId: Int?
|
||||
// Nur fuer Gegenstaende (Standard nil = nicht mitschicken bei fieldValues).
|
||||
var shopId: Int? = nil
|
||||
var productUrl: String? = nil
|
||||
var fieldValues: [String: String?]? = nil
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name, brand
|
||||
@@ -361,6 +380,9 @@ struct ProductUpdateRequest: Encodable {
|
||||
case datePrecision = "date_precision"
|
||||
case groupId = "group_id"
|
||||
case categoryId = "category_id"
|
||||
case shopId = "shop_id"
|
||||
case productUrl = "product_url"
|
||||
case fieldValues = "field_values"
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
@@ -372,6 +394,10 @@ struct ProductUpdateRequest: Encodable {
|
||||
try container.encode(datePrecision, forKey: .datePrecision)
|
||||
try container.encode(groupId, forKey: .groupId)
|
||||
try container.encode(categoryId, forKey: .categoryId)
|
||||
try container.encode(shopId, forKey: .shopId)
|
||||
try container.encode(productUrl, forKey: .productUrl)
|
||||
// Feldwerte nur senden, wenn gesetzt – sonst nichts an den Feldern ändern.
|
||||
if let fieldValues { try container.encode(fieldValues, forKey: .fieldValues) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,16 +425,143 @@ struct GroupItem: Codable, Identifiable, Hashable {
|
||||
let name: String
|
||||
}
|
||||
|
||||
/// Kategorie: ordnet nur die Artikelliste, verschachtelbar (Suesswaren -> Schokolade).
|
||||
/// Kategorie: ordnet die Artikelliste und bestimmt die Verwaltungsart
|
||||
/// ("food"/"object"), verschachtelbar (Suesswaren -> Schokolade).
|
||||
struct CategoryItem: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let parentId: Int?
|
||||
let tracking: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name
|
||||
case id, name, tracking
|
||||
case parentId = "parent_id"
|
||||
}
|
||||
|
||||
var isObject: Bool { tracking == "object" }
|
||||
}
|
||||
|
||||
/// Shop / Bezugsquelle fuer Gegenstaende ("gekauft bei").
|
||||
struct ShopItem: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let website: String?
|
||||
let productCount: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, website
|
||||
case productCount = "product_count"
|
||||
}
|
||||
}
|
||||
|
||||
/// Selbst definiertes Feld einer Kategorie (inkl. der geerbten).
|
||||
struct FieldDefinition: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let categoryId: Int
|
||||
let label: String
|
||||
let key: String
|
||||
let fieldType: String
|
||||
let unit: String?
|
||||
let options: [String]
|
||||
let required: Bool
|
||||
let position: Int
|
||||
let isBuiltin: Bool
|
||||
let inherited: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, label, key, unit, options, required, position, inherited
|
||||
case categoryId = "category_id"
|
||||
case fieldType = "field_type"
|
||||
case isBuiltin = "is_builtin"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gegenstaende: Bestandsbuchungen
|
||||
|
||||
struct ObjectCheckInRequest: Codable {
|
||||
let productId: Int
|
||||
let quantity: Double
|
||||
let unit: String
|
||||
let locationId: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case quantity, unit
|
||||
case productId = "product_id"
|
||||
case locationId = "location_id"
|
||||
}
|
||||
}
|
||||
|
||||
struct RelocateRequest: Codable {
|
||||
let productId: Int
|
||||
let quantity: Double
|
||||
let fromLocationId: Int?
|
||||
let toLocationId: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case quantity
|
||||
case productId = "product_id"
|
||||
case fromLocationId = "from_location_id"
|
||||
case toLocationId = "to_location_id"
|
||||
}
|
||||
}
|
||||
|
||||
struct RemoveRequest: Codable {
|
||||
let productId: Int
|
||||
let quantity: Double
|
||||
let locationId: Int?
|
||||
let reason: String
|
||||
let note: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case quantity, reason, note
|
||||
case productId = "product_id"
|
||||
case locationId = "location_id"
|
||||
}
|
||||
}
|
||||
|
||||
struct RemovalStat: Codable, Identifiable, Hashable {
|
||||
let reason: String
|
||||
let quantity: Double
|
||||
let count: Int
|
||||
var id: String { reason }
|
||||
}
|
||||
|
||||
struct RemovalHistoryItem: Codable, Identifiable, Hashable {
|
||||
let reason: String
|
||||
let quantity: Double
|
||||
let locationId: Int?
|
||||
let locationName: String?
|
||||
let note: String?
|
||||
let username: String?
|
||||
let createdAt: String
|
||||
var id: String { "\(reason)-\(createdAt)-\(quantity)" }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case reason, quantity, note, username
|
||||
case locationId = "location_id"
|
||||
case locationName = "location_name"
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
}
|
||||
|
||||
struct RemovalSummary: Codable {
|
||||
let stats: [RemovalStat]
|
||||
let history: [RemovalHistoryItem]
|
||||
}
|
||||
|
||||
/// Anzeige-Bezeichnungen der Entnahmegruende.
|
||||
enum RemovalReasons {
|
||||
static let all: [(value: String, label: String)] = [
|
||||
("broken", "kaputt"),
|
||||
("lost", "verloren"),
|
||||
("given_away", "verschenkt"),
|
||||
("sold", "verkauft"),
|
||||
("used_up", "aufgebraucht"),
|
||||
("other", "sonstiges"),
|
||||
]
|
||||
static func label(_ value: String) -> String {
|
||||
all.first { $0.value == value }?.label ?? value
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Uebersicht und Verlauf
|
||||
|
||||
327
ios/Sources/ObjectStockView.swift
Normal file
327
ios/Sources/ObjectStockView.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,14 @@ struct ProductDetailView: View {
|
||||
@State private var groups: [GroupItem] = []
|
||||
@State private var categories: [CategoryItem] = []
|
||||
|
||||
// Gegenstände (Non-Food): Bezugsquelle, Link, Lagerorte und eigene Felder.
|
||||
@State private var shopId: Int?
|
||||
@State private var productUrl = ""
|
||||
@State private var shops: [ShopItem] = []
|
||||
@State private var locations: [StorageLocation] = []
|
||||
@State private var fieldDefs: [FieldDefinition] = []
|
||||
@State private var fieldValues: [String: String] = [:]
|
||||
|
||||
@State private var editLot: Lot?
|
||||
|
||||
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
|
||||
@@ -41,12 +49,17 @@ struct ProductDetailView: View {
|
||||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||
}
|
||||
|
||||
Section("Bestand") {
|
||||
LabeledContent("Vorrat",
|
||||
value: "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)")
|
||||
if current.expiredCount > 0 {
|
||||
LabeledContent("Abgelaufen", value: "\(current.expiredCount)")
|
||||
.foregroundStyle(.red)
|
||||
if current.isObject {
|
||||
ObjectStockSection(product: current, locations: locations, lots: lots,
|
||||
onChanged: { await reload() })
|
||||
} else {
|
||||
Section("Bestand") {
|
||||
LabeledContent("Vorrat",
|
||||
value: "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)")
|
||||
if current.expiredCount > 0 {
|
||||
LabeledContent("Abgelaufen", value: "\(current.expiredCount)")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,33 +68,57 @@ struct ProductDetailView: View {
|
||||
Section("Artikel") {
|
||||
LabeledField(label: "Name", text: $name)
|
||||
LabeledField(label: "Marke", text: $brand)
|
||||
QuantityField(label: "Packungsgröße", text: $packageSize,
|
||||
suffix: display.baseUnitLabel(current.baseUnit))
|
||||
Picker("Bezeichnung", selection: $packageLabel) {
|
||||
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
|
||||
}
|
||||
Picker("MHD-Angabe", selection: $datePrecision) {
|
||||
Text("Tagesdatum").tag("day")
|
||||
Text("nur Monat/Jahr").tag("month")
|
||||
if !current.isObject {
|
||||
QuantityField(label: "Packungsgröße", text: $packageSize,
|
||||
suffix: display.baseUnitLabel(current.baseUnit))
|
||||
Picker("Bezeichnung", selection: $packageLabel) {
|
||||
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
|
||||
}
|
||||
Picker("MHD-Angabe", selection: $datePrecision) {
|
||||
Text("Tagesdatum").tag("day")
|
||||
Text("nur Monat/Jahr").tag("month")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
CategoryPicker(categories: categories, selection: $categoryId)
|
||||
} footer: {
|
||||
Text("Kategorie: nur für den Überblick in der Artikelliste.")
|
||||
Text(current.isObject
|
||||
? "Kategorie bestimmt die Verwaltungsart (Gegenstand) und die eigenen Felder."
|
||||
: "Kategorie: nur für den Überblick in der Artikelliste.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Gruppe", selection: $groupId) {
|
||||
Text("– keine –").tag(Int?.none)
|
||||
ForEach(groups) { gruppe in
|
||||
Text(gruppe.name).tag(Int?.some(gruppe.id))
|
||||
if current.isObject {
|
||||
Section("Gekauft bei") {
|
||||
Picker("Shop", selection: $shopId) {
|
||||
Text("– unbekannt / mehrere –").tag(Int?.none)
|
||||
ForEach(shops) { shop in Text(shop.name).tag(Int?.some(shop.id)) }
|
||||
}
|
||||
LabeledField(label: "Produktlink", text: $productUrl)
|
||||
if !productUrl.isEmpty, let url = URL(string: productUrl) {
|
||||
Link("Im Onlineshop öffnen", destination: url)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("Gruppe: zählt Bestände mehrerer Marken zusammen. Der EAN-Code "
|
||||
+ "wandert bei einem Wechsel mit.")
|
||||
if !fieldDefs.isEmpty {
|
||||
Section("Eigene Felder") {
|
||||
ForEach(fieldDefs) { def in
|
||||
ObjectFieldRow(def: def, value: fieldBinding(def))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
Picker("Gruppe", selection: $groupId) {
|
||||
Text("– keine –").tag(Int?.none)
|
||||
ForEach(groups) { gruppe in
|
||||
Text(gruppe.name).tag(Int?.some(gruppe.id))
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("Gruppe: zählt Bestände mehrerer Marken zusammen. Der EAN-Code "
|
||||
+ "wandert bei einem Wechsel mit.")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Erkennung") {
|
||||
@@ -96,28 +133,30 @@ struct ProductDetailView: View {
|
||||
.disabled(busy || name.isEmpty)
|
||||
}
|
||||
|
||||
Section("Chargen") {
|
||||
ForEach(lots) { lot in
|
||||
Button {
|
||||
editLot = lot
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))")
|
||||
Text("\(formatAmount(lot.quantity / current.articleUnitFactor)) \(current.articleUnitLabel)")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
if !current.isObject {
|
||||
Section("Chargen") {
|
||||
ForEach(lots) { lot in
|
||||
Button {
|
||||
editLot = lot
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))")
|
||||
Text("\(formatAmount(lot.quantity / current.articleUnitFactor)) \(current.articleUnitLabel)")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
Task { await deleteLots(at: indexSet) }
|
||||
}
|
||||
if lots.isEmpty {
|
||||
Text("Keine Chargen im Bestand.").foregroundStyle(.secondary)
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
Task { await deleteLots(at: indexSet) }
|
||||
}
|
||||
if lots.isEmpty {
|
||||
Text("Keine Chargen im Bestand.").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +181,16 @@ struct ProductDetailView: View {
|
||||
fillForm()
|
||||
await reload()
|
||||
}
|
||||
.onChange(of: categoryId) { newId in
|
||||
// Bei Wechsel auf eine Gegenstands-Kategorie deren Felder nachladen.
|
||||
Task {
|
||||
if let cid = newId, categories.first(where: { $0.id == cid })?.isObject == true {
|
||||
fieldDefs = (try? await APIClient.shared.categoryFields(categoryId: cid)) ?? []
|
||||
} else {
|
||||
fieldDefs = []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fillForm() {
|
||||
@@ -152,15 +201,37 @@ struct ProductDetailView: View {
|
||||
datePrecision = current.datePrecision == "month" ? "month" : "day"
|
||||
groupId = current.groupId
|
||||
categoryId = current.categoryId
|
||||
shopId = current.shopId
|
||||
productUrl = current.productUrl ?? ""
|
||||
fieldValues = Self.stringValues(current.fieldValues)
|
||||
}
|
||||
|
||||
private static func stringValues(_ dict: [String: String?]?) -> [String: String] {
|
||||
guard let dict else { return [:] }
|
||||
var out: [String: String] = [:]
|
||||
for (schluessel, wert) in dict { if let wert { out[schluessel] = wert } }
|
||||
return out
|
||||
}
|
||||
|
||||
private func fieldBinding(_ def: FieldDefinition) -> Binding<String> {
|
||||
let key = String(def.id)
|
||||
return Binding(get: { fieldValues[key] ?? "" }, set: { fieldValues[key] = $0 })
|
||||
}
|
||||
|
||||
private func reload() async {
|
||||
groups = (try? await APIClient.shared.groups()) ?? []
|
||||
categories = (try? await APIClient.shared.categories()) ?? []
|
||||
lots = (try? await APIClient.shared.lots(productId: current.id)) ?? []
|
||||
shops = (try? await APIClient.shared.shops()) ?? []
|
||||
locations = (try? await APIClient.shared.locations()) ?? []
|
||||
if let frisch = try? await APIClient.shared.product(id: current.id) {
|
||||
current = frisch
|
||||
}
|
||||
if current.isObject, let cid = current.categoryId {
|
||||
fieldDefs = (try? await APIClient.shared.categoryFields(categoryId: cid)) ?? []
|
||||
} else {
|
||||
fieldDefs = []
|
||||
}
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
@@ -169,18 +240,37 @@ struct ProductDetailView: View {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
do {
|
||||
current = try await APIClient.shared.updateProduct(
|
||||
id: current.id,
|
||||
ProductUpdateRequest(
|
||||
name: name,
|
||||
brand: brand.isEmpty ? nil : brand,
|
||||
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
|
||||
packageLabel: packageLabel.isEmpty ? nil : packageLabel,
|
||||
datePrecision: datePrecision,
|
||||
groupId: groupId,
|
||||
categoryId: categoryId
|
||||
if current.isObject {
|
||||
// Gegenstände: keine Lebensmittel-Felder, dafür Shop, Link und eigene Felder.
|
||||
var fv: [String: String?] = [:]
|
||||
for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" }
|
||||
current = try await APIClient.shared.updateProduct(
|
||||
id: current.id,
|
||||
ProductUpdateRequest(
|
||||
name: name,
|
||||
brand: brand.isEmpty ? nil : brand,
|
||||
packageSize: nil, packageLabel: nil, datePrecision: nil,
|
||||
groupId: nil, categoryId: categoryId,
|
||||
shopId: shopId,
|
||||
productUrl: productUrl.isEmpty ? nil : productUrl,
|
||||
fieldValues: fv
|
||||
)
|
||||
)
|
||||
)
|
||||
fieldValues = Self.stringValues(current.fieldValues)
|
||||
} else {
|
||||
current = try await APIClient.shared.updateProduct(
|
||||
id: current.id,
|
||||
ProductUpdateRequest(
|
||||
name: name,
|
||||
brand: brand.isEmpty ? nil : brand,
|
||||
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
|
||||
packageLabel: packageLabel.isEmpty ? nil : packageLabel,
|
||||
datePrecision: datePrecision,
|
||||
groupId: groupId,
|
||||
categoryId: categoryId
|
||||
)
|
||||
)
|
||||
}
|
||||
status = "Gespeichert."
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */; };
|
||||
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F574168AA0F849D46C384EE /* ProductDetailView.swift */; };
|
||||
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */; };
|
||||
D7717F44D2CDB75DC2693B59 /* ObjectStockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */; };
|
||||
DFA55EF4ACA34537F445E998 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2F9406BD0D4F0D30FED345 /* Session.swift */; };
|
||||
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A75926511854BB8EE316ED3A /* LoginView.swift */; };
|
||||
ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717C8EB336170526F5F3E695 /* DateScanView.swift */; };
|
||||
@@ -57,6 +58,7 @@
|
||||
660ACEFA22BC77D52FBE736A /* AppDashboards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDashboards.swift; sourceTree = "<group>"; };
|
||||
6F574168AA0F849D46C384EE /* ProductDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailView.swift; sourceTree = "<group>"; };
|
||||
717C8EB336170526F5F3E695 /* DateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateScanView.swift; sourceTree = "<group>"; };
|
||||
7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectStockView.swift; sourceTree = "<group>"; };
|
||||
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
||||
8AF19A735AC17451B57BF5BB /* ScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerView.swift; sourceTree = "<group>"; };
|
||||
939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = "<group>"; };
|
||||
@@ -100,6 +102,7 @@
|
||||
163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */,
|
||||
3731608B8D48960DC98912BC /* NotificationSettings.swift */,
|
||||
939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */,
|
||||
7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */,
|
||||
6F574168AA0F849D46C384EE /* ProductDetailView.swift */,
|
||||
369B9841E43E727ACA2E2A2A /* ProductViews.swift */,
|
||||
4741D0E95875919C921945CF /* RootView.swift */,
|
||||
@@ -222,6 +225,7 @@
|
||||
38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */,
|
||||
4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */,
|
||||
17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */,
|
||||
D7717F44D2CDB75DC2693B59 /* ObjectStockView.swift in Sources */,
|
||||
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */,
|
||||
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */,
|
||||
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */,
|
||||
|
||||
Reference in New Issue
Block a user