App: Obergruppen zuordnen, Mindestbestaende je Ort bearbeiten

Die App konnte bei Gruppen bisher nur umbenennen und Mindestbestaende lesen.
Jetzt gibt es einen vollen Gruppen-Editor (Sources/GroupViews.swift): Name,
Obergruppen als Mehrfachauswahl, Einheit und Mindestbestaende je Ort. Bewusst
eine flache Liste mit "unter: ..." statt TreeMasterView - das Protokoll TreeItem
kennt genau einen Elternteil und wuerde eine Gruppe mit zwei Obergruppen doppelt
zeigen. Die eigene Gruppe und ihre Untergruppen sind in der Auswahl
ausgeschlossen, damit kein Ring entsteht.

Mindestbestaende-Uebersicht ist nach Lagerort gegliedert ("Ueberall" zuerst) und
nicht mehr nur lesend - Gruppenzeilen oeffnen den neuen Editor. Am Artikel
entfaellt das separate Feld "Gesamt": der Bedarf haengt immer an einem Ort, und
"Ueberall" ist einer davon. Das Stammdaten-Speichern schickt min_stock nicht
mehr mit, sonst wuerde es die dort gesetzte Ueberall-Zeile ueberschreiben.

APIClient kann jetzt updateGroup und setGroupLocationMinStock. GroupUpdateRequest
bekommt bewusst KEINEN eigenen Encoder: der synthetisierte laesst nil-Felder weg,
ein handgeschriebener wuerde "name": null senden und den Namen loeschen.

Neue Datei -> xcodegen generate; Projektdatei ist mit committet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-16 00:02:43 +02:00
parent 0537a2b53f
commit f33054c534
8 changed files with 576 additions and 133 deletions

View File

@@ -527,6 +527,20 @@ actor APIClient {
try await sendNoContent(try makeRequest("/categories/\(id)", method: "DELETE"))
}
/// Gruppe aendern (Name, Mindestbestand, Einheit, Obergruppen).
func updateGroup(id: Int, _ payload: GroupUpdateRequest) async throws -> GroupItem {
var request = try makeRequest("/groups/\(id)", method: "PATCH")
try jsonBody(&request, payload)
return try await send(request, as: GroupItem.self)
}
/// Mindestbestaende einer Gruppe je Lagerort ersetzen (Basiseinheiten).
func setGroupLocationMinStock(id: Int, _ list: [LocationMinStockIn]) async throws -> GroupItem {
var request = try makeRequest("/groups/\(id)/location-min-stock", method: "PUT")
try jsonBody(&request, list)
return try await send(request, as: GroupItem.self)
}
func renameGroup(id: Int, name: String) async throws -> GroupItem {
var request = try makeRequest("/groups/\(id)", method: "PATCH")
try jsonBody(&request, RenameRequest(name: name))

View File

@@ -0,0 +1,327 @@
import SwiftUI
// Gruppen: Stammdaten, Obergruppen und Mindestbestände.
//
// Gruppen bilden einen Graphen, keinen Baum: eine Gruppe darf unter MEHREREN
// Obergruppen hängen (Grillwurst" unter Wurst" UND unter Grillgut").
// `TreeMasterView` (MasterDataViews.swift) kann nur einen Elternteil und würde
// dieselbe Gruppe doppelt anzeigen deshalb hier eine eigene, flache Ansicht
// statt der Baumdarstellung der Lagerorte.
/// Gruppenliste mit unter: " als Untertitel.
struct GroupsView: View {
@State private var groups: [GroupItem] = []
@State private var busy = true
@State private var error: String?
@State private var editing: GroupItem?
@State private var anlegen = false
@State private var loeschen: GroupItem?
private var sortiert: [GroupItem] {
groups.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
var body: some View {
List {
if !busy && groups.isEmpty {
Text("Noch keine Gruppen.").foregroundStyle(.secondary)
}
ForEach(sortiert) { g in
Button { editing = g } label: { zeile(g) }
.buttonStyle(.plain)
.swipeActions {
Button(role: .destructive) { loeschen = g } label: {
Label("Löschen", systemImage: "trash")
}
}
}
}
.navigationTitle("Gruppen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { anlegen = true } label: { Image(systemName: "plus") }
}
}
.overlay { if busy && groups.isEmpty { ProgressView() } }
.refreshable { await load() }
.task { await load() }
.sheet(item: $editing) { g in
NavigationStack { GroupEditor(item: g, all: groups) { Task { await load() } } }
}
.sheet(isPresented: $anlegen) {
NavigationStack { GroupEditor(item: nil, all: groups) { Task { await load() } } }
}
.confirmationDialog("Gruppe löschen?", isPresented: Binding(
get: { loeschen != nil }, set: { if !$0 { loeschen = nil } }
), titleVisibility: .visible) {
Button("Löschen", role: .destructive) {
if let g = loeschen { Task { await entfernen(g) } }
}
Button("Abbrechen", role: .cancel) { loeschen = nil }
} message: {
Text("Die Artikel bleiben erhalten und verlieren nur ihre Zuordnung. "
+ "Untergruppen bleiben ebenfalls bestehen sie rücken nicht nach oben.")
}
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("OK", role: .cancel) {}
} message: { Text(error ?? "") }
}
@ViewBuilder
private func zeile(_ g: GroupItem) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(g.name).foregroundStyle(.primary)
Text(untertitel(g)).font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
private func untertitel(_ g: GroupItem) -> String {
let map = Dictionary(groups.map { ($0.id, $0.name) }, uniquingKeysWith: { a, _ in a })
let eltern = (g.parentIds ?? []).compactMap { map[$0] }
var teile = [eltern.isEmpty ? "oberste Ebene" : "unter: " + eltern.joined(separator: ", ")]
teile.append("\(g.productCount ?? 0) Artikel")
if let n = g.childIds?.count, n > 0 { teile.append("\(n) Untergruppen") }
return teile.joined(separator: " · ")
}
private func load() async {
busy = true
defer { busy = false }
do { groups = try await APIClient.shared.groups() }
catch { self.error = error.localizedDescription }
}
private func entfernen(_ g: GroupItem) async {
loeschen = nil
do {
try await APIClient.shared.deleteGroup(id: g.id)
await load()
} catch { self.error = error.localizedDescription }
}
}
/// Gruppe anlegen/ändern: Name, Obergruppen, Einheit und Mindestbestände je Ort.
struct GroupEditor: View {
let item: GroupItem?
let all: [GroupItem]
var done: () -> Void
@Environment(\.dismiss) private var dismiss
private struct MinRow: Identifiable {
let id = UUID()
var locationId: String?
var amount: String
}
@State private var name = ""
@State private var parentIds: Set<Int> = []
@State private var rows: [MinRow] = []
@State private var units: [Unit] = []
@State private var unitId: Int?
@State private var locations: [StorageLocation] = []
@State private var busy = false
@State private var error: String?
/// Basiseinheiten je Erfassungseinheit gespeichert wird in Basiseinheiten.
private var faktor: Double {
let f = item?.minFaktor ?? 1
return f == 0 ? 1 : f
}
private var einheit: String { item?.minEinheit ?? "" }
/// Auswahlmenge für Obergruppen: alles außer der Gruppe selbst und ihren
/// Untergruppen sonst entstünde ein Ring.
private var moeglicheEltern: [GroupItem] {
guard let item else { return all }
let verboten = Self.nachfahren(of: item.id, in: all).union([item.id])
return all.filter { !verboten.contains($0.id) }
}
private var elternText: String {
let map = Dictionary(all.map { ($0.id, $0.name) }, uniquingKeysWith: { a, _ in a })
let liste = parentIds.compactMap { map[$0] }.sorted()
return liste.isEmpty ? " keine " : liste.joined(separator: ", ")
}
private var mengenTitel: String {
einheit.isEmpty ? "Mindestbestand" : "Mindestbestand (in \(einheit))"
}
var body: some View {
Form {
Section("Name") {
TextField("z.B. Wurst", text: $name)
}
Section {
NavigationLink {
GroupParentPicker(auswahl: $parentIds, moeglich: moeglicheEltern)
} label: {
LabeledContent("Obergruppen", value: elternText)
}
} footer: {
Text("Eine Gruppe darf unter mehreren Obergruppen hängen „Grillwurst“ "
+ "etwa unter „Wurst“ und unter „Grillgut“. Bestand und Mindestbestand "
+ "der Obergruppe zählen sie dann mit.")
}
if item != nil {
Section("Einheit") {
Picker("Einheit", selection: $unitId) {
Text(" Basiseinheit ").tag(Int?.none)
ForEach(units) { u in Text(u.name).tag(Int?.some(u.id)) }
}
}
Section {
ForEach($rows) { $row in
mengenZeile($row)
}
Button {
rows.append(MinRow(locationId: UEBERALL_ID, amount: ""))
} label: {
Label("Mindestbestand hinzufügen", systemImage: "plus")
}
} header: {
Text(mengenTitel)
} footer: {
Text("„Überall“ heißt: egal wo, Hauptsache im Haus Käufe für einen "
+ "Lagerort decken das mit ab.")
}
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
}
.navigationTitle(item == nil ? "Gruppe anlegen" : "Gruppe ändern")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
ToolbarItem(placement: .topBarTrailing) {
Button(busy ? "Speichern…" : "Speichern") { Task { await save() } }
.disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.task { await load() }
}
@ViewBuilder
private func mengenZeile(_ row: Binding<MinRow>) -> some View {
HStack {
Picker("Ort", selection: row.locationId) {
Text(UEBERALL_NAME).tag(String?.some(UEBERALL_ID))
ForEach(locations) { l in
Text(l.path(in: locations)).tag(String?.some(l.id))
}
}
TextField("Menge", text: row.amount)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 70)
Button(role: .destructive) {
let id = row.wrappedValue.id
rows.removeAll { $0.id == id }
} label: { Image(systemName: "trash") }
.buttonStyle(.borderless)
}
}
private func load() async {
name = item?.name ?? ""
parentIds = Set(item?.parentIds ?? [])
unitId = item?.minStockUnitId
units = (try? await APIClient.shared.units()) ?? []
locations = (try? await APIClient.shared.locations()) ?? []
rows = (item?.locationMinStocks ?? []).map {
MinRow(locationId: $0.locationId ?? UEBERALL_ID,
amount: formatAmount($0.minStock / faktor))
}
}
private func save() async {
busy = true
defer { busy = false }
let sauber = name.trimmingCharacters(in: .whitespaces)
do {
guard let item else {
_ = try await APIClient.shared.createGroup(NewGroupRequest(
name: sauber, minStock: nil, minStockUnitId: unitId,
parentIds: Array(parentIds)))
done()
dismiss()
return
}
_ = try await APIClient.shared.updateGroup(id: item.id, GroupUpdateRequest(
name: sauber, minStockUnitId: unitId, parentIds: Array(parentIds)))
var liste: [LocationMinStockIn] = []
var gesehen: Set<String> = []
for row in rows {
guard let loc = row.locationId, !gesehen.contains(loc) else { continue }
let wert = Double(row.amount.replacingOccurrences(of: ",", with: ".")) ?? 0
if wert > 0 {
gesehen.insert(loc)
// Eingabe in der Erfassungseinheit Basiseinheiten.
liste.append(LocationMinStockIn(
locationId: loc == UEBERALL_ID ? nil : loc,
minStock: wert * faktor))
}
}
_ = try await APIClient.shared.setGroupLocationMinStock(id: item.id, liste)
done()
dismiss()
} catch {
self.error = error.localizedDescription
}
}
/// Alle Untergruppen (transitiv). Menge statt Baum-Walk: eine Gruppe ist
/// über mehrere Wege erreichbar und würde sonst mehrfach besucht.
private static func nachfahren(of id: Int, in all: [GroupItem]) -> Set<Int> {
var ergebnis: Set<Int> = []
var offen = [id]
while let cur = offen.popLast() {
for kind in all.first(where: { $0.id == cur })?.childIds ?? [] {
if ergebnis.insert(kind).inserted { offen.append(kind) }
}
}
return ergebnis
}
}
/// Mehrfachauswahl der Obergruppen. Ein Picker kann das nicht deshalb eine
/// Liste mit Häkchen, das SwiftUI-Idiom dafür.
struct GroupParentPicker: View {
@Binding var auswahl: Set<Int>
let moeglich: [GroupItem]
private var sortiert: [GroupItem] {
moeglich.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
var body: some View {
List {
ForEach(sortiert) { g in
Button {
if auswahl.contains(g.id) { auswahl.remove(g.id) } else { auswahl.insert(g.id) }
} label: {
HStack {
Text(g.name).foregroundStyle(.primary)
Spacer()
if auswahl.contains(g.id) {
Image(systemName: "checkmark").foregroundStyle(Color.marke)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
.navigationTitle("Obergruppen")
.navigationBarTitleDisplayMode(.inline)
}
}

View File

@@ -76,7 +76,10 @@ struct ShoppingListView: View {
ListRow(
systemImage: "square.stack.3d.up",
title: group.name,
subtitle: "fehlt \(formatAmount(group.deficit)) \(group.unitName) · \(group.productCount) Artikel"
subtitle: "fehlt \(formatAmount(group.deficit)) \(group.unitName)"
+ " · \(group.productCount) Artikel"
+ ((group.subgroupCount ?? 0) > 0
? " · inkl. \(group.subgroupCount ?? 0) Untergruppen" : "")
) { GroupBadge() }
}
}
@@ -185,10 +188,15 @@ struct ExpiringView: View {
// MARK: - Mindestbestände
/// Übersicht aller Mindestbestände: Produkte und Gruppen, jeweils Gesamt UND je
/// Lagerort (Soll in der Anzeige-/Gruppeneinheit). Produktzeilen öffnen den
/// Artikel zum Bearbeiten; das Setzen von Gruppen-/Lagerort-Bedarfen läuft übers
/// Web.
/// Übersicht aller Mindestbestände nach LAGERORT gegliedert.
///
/// Ein Mindestbestand ist immer ein Bedarf an einem Ort. Überall" (egal wo,
/// Hauptsache im Haus) ist dabei der oberste Ort; Käufe für einen einzelnen
/// Lagerort decken ihn mit ab. Mengen kommen in Basiseinheiten und werden hier
/// in die Erfassungseinheit des Ziels umgerechnet.
///
/// Artikelzeilen öffnen den Artikel, Gruppenzeilen die Gruppe beide lassen
/// sich dort bearbeiten.
struct MinStockView: View {
@State private var products: [Product] = []
@State private var groups: [GroupItem] = []
@@ -198,71 +206,92 @@ struct MinStockView: View {
private struct MinRow: Identifiable {
let id: String
let title: String
let ort: String // "Gesamt" oder Lagerort-Name
let soll: String
let bestand: String?
let productId: Int? // Tippziel (nur Produkte)
let isGroup: Bool
let productId: Int? // Tippziel (Artikel)
let groupId: Int? // Tippziel (Gruppe)
let unter: Bool
let untergruppen: Int
}
private struct OrtBlock: Identifiable {
let id: String
let name: String
let zeilen: [MinRow]
}
/// Alle Zeilen, gebündelt je Ort. Überall" (Ort nil) steht zuerst.
private var bloecke: [OrtBlock] {
var jeOrt: [String: [MinRow]] = [:]
var namen: [String: String] = [UEBERALL_ID: UEBERALL_NAME]
func merken(_ locationId: String?, _ locationName: String?, _ row: MinRow) {
let key = locationId ?? UEBERALL_ID
if let n = locationName, locationId != nil { namen[key] = n }
jeOrt[key, default: []].append(row)
}
private var rows: [MinRow] {
var out: [MinRow] = []
for p in products.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) {
let disp = p.unitFactor == 0 ? 1 : p.unitFactor // Basis -> Anzeigeeinheit
let art = p.articleUnitFactor == 0 ? 1 : p.articleUnitFactor
if let ms = p.minStock, ms > 0 {
out.append(MinRow(
id: "p\(p.id)g", title: p.name, ort: "Gesamt",
soll: "\(formatAmount(ms / disp)) \(p.unitName)",
bestand: "\(formatAmount(p.stock / disp)) \(p.unitName)",
productId: p.id, isGroup: false, unter: p.stock < ms))
}
for l in (p.locationMinStocks ?? []) {
// je Lagerort in Artikeleinheiten gespeichert -> Anzeigeeinheit.
out.append(MinRow(
id: "p\(p.id)l\(l.locationId)", title: p.name,
ort: l.locationName ?? "Lagerort",
soll: "\(formatAmount(l.minStock * art / disp)) \(p.unitName)",
bestand: nil, productId: p.id, isGroup: false, unter: false))
merken(l.locationId, l.locationName, MinRow(
id: "p\(p.id)l\(l.id)", title: p.name,
soll: "\(formatAmount(l.minStock / disp)) \(p.unitName)",
bestand: l.stock.map { "\(formatAmount($0 / disp)) \(p.unitName)" },
productId: p.id, groupId: nil,
unter: (l.stock ?? 0) < l.minStock, untergruppen: 0))
}
}
for g in groups.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) {
let unit = g.minStockUnitName ?? ""
if let ms = g.minStock, ms > 0 {
out.append(MinRow(
id: "g\(g.id)g", title: g.name, ort: "Gesamt",
soll: "\(formatAmount(ms)) \(unit)",
bestand: g.stock.map { "\(formatAmount($0)) \(unit)" },
productId: nil, isGroup: true, unter: (g.stock ?? 0) < ms))
}
let faktor = g.minFaktor == 0 ? 1 : g.minFaktor
let einheit = g.minEinheit
for l in (g.locationMinStocks ?? []) {
out.append(MinRow(
id: "g\(g.id)l\(l.locationId)", title: g.name,
ort: l.locationName ?? "Lagerort",
soll: "\(formatAmount(l.minStock)) \(unit)",
bestand: nil, productId: nil, isGroup: true, unter: false))
merken(l.locationId, l.locationName, MinRow(
id: "g\(g.id)l\(l.id)", title: g.name,
soll: "\(formatAmount(l.minStock / faktor)) \(einheit)",
bestand: l.stock.map { "\(formatAmount($0 / faktor)) \(einheit)" },
productId: nil, groupId: g.id,
unter: (l.stock ?? 0) < l.minStock,
untergruppen: g.childIds?.count ?? 0))
}
}
return out
// Überall" zuerst, danach die Lagerorte alphabetisch.
let schluessel = jeOrt.keys.sorted { a, b in
if a == UEBERALL_ID { return true }
if b == UEBERALL_ID { return false }
return (namen[a] ?? "").localizedCaseInsensitiveCompare(namen[b] ?? "") == .orderedAscending
}
return schluessel.map {
OrtBlock(id: $0, name: namen[$0] ?? "Lagerort",
zeilen: jeOrt[$0] ?? [])
}
}
var body: some View {
List {
if !busy && rows.isEmpty {
if !busy && bloecke.isEmpty {
Text("Noch keine Mindestbestände gesetzt.").foregroundStyle(.secondary)
}
ForEach(rows) { r in
ForEach(bloecke) { block in
Section(block.id == UEBERALL_ID ? "Überall (egal wo)" : block.name) {
ForEach(block.zeilen) { r in
if let pid = r.productId, let p = products.first(where: { $0.id == pid }) {
NavigationLink { ProductDetailView(product: p) } label: { zeile(r) }
} else if let gid = r.groupId, let g = groups.first(where: { $0.id == gid }) {
NavigationLink {
GroupEditor(item: g, all: groups) { Task { await load() } }
} label: { zeile(r) }
} else {
zeile(r)
}
}
}
}
}
.navigationTitle("Mindestbestände")
.navigationBarTitleDisplayMode(.inline)
.overlay { if busy && rows.isEmpty { ProgressView() } }
.overlay { if busy && bloecke.isEmpty { ProgressView() } }
.refreshable { await load() }
.onAppear { Task { await load() } }
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
@@ -276,13 +305,18 @@ struct MinStockView: View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(r.title)
if r.isGroup {
if r.groupId != nil {
Text("Gruppe").font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 1)
.background(Color.marke.opacity(0.2), in: Capsule())
}
if r.untergruppen > 0 {
Text("inkl. \(r.untergruppen)").font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 1)
.background(Color.secondary.opacity(0.15), in: Capsule())
}
Text("\(r.ort) · Soll \(r.soll)" + (r.bestand.map { " · Bestand \($0)" } ?? ""))
}
Text("Soll \(r.soll)" + (r.bestand.map { " · Bestand \($0)" } ?? ""))
.font(.caption).foregroundStyle(.secondary)
}
Spacer()

View File

@@ -1126,34 +1126,6 @@ struct FieldEditor: View {
}
}
struct GroupsView: View {
var body: some View {
MasterDataListView(
title: "Gruppen",
singular: "Die Gruppe",
load: {
try await APIClient.shared.groups().map {
MasterDataItem(id: $0.id, title: $0.name, subtitle: "", isBuiltin: false)
}
},
delete: { try await APIClient.shared.deleteGroup(id: $0) }
) { item, done in
NameEditor(
title: item == nil ? "Gruppe anlegen" : "Gruppe umbenennen",
initial: item?.title ?? "",
save: { name in
if let item {
_ = try await APIClient.shared.renameGroup(id: item.id, name: name)
} else {
_ = try await APIClient.shared.createGroup(
NewGroupRequest(name: name, minStock: nil, minStockUnitId: nil))
}
},
done: done
)
}
}
}
/// Bezugsquellen für gekauft bei" (nur Gegenstände). Wie im Web pflegbar
/// Name plus optionale Website.

View File

@@ -146,29 +146,49 @@ struct Product: Codable, Identifiable, Hashable {
}
/// Mindestbestand eines Produkts/einer Gruppe an einem Lagerort (Anzeige).
///
/// ``locationId`` nil heisst Ueberall": egal wo, Hauptsache die Menge ist im
/// Haus. Das ersetzt den frueheren separaten Gesamt-Mindestbestand. Mengen in
/// Basiseinheiten (g/ml/Stueck).
struct LocationMinStock: Codable, Hashable, Identifiable {
let locationId: String
let locationId: String?
let locationName: String?
let minStock: Double
var id: String { locationId }
/// Bestand an diesem Ort (inkl. Unterorte), ebenfalls in Basiseinheiten.
var stock: Double? = nil
var id: String { locationId ?? UEBERALL_ID }
enum CodingKeys: String, CodingKey {
case stock
case minStock = "min_stock"
case locationId = "location_id"
case locationName = "location_name"
}
}
/// Ein Mindestbestand-Eintrag zum Speichern (Menge in Artikeleinheiten).
/// Ein Mindestbestand-Eintrag zum Speichern (Menge in Basiseinheiten).
/// ``locationId`` nil = Ueberall".
struct LocationMinStockIn: Codable {
let locationId: String
let locationId: String?
let minStock: Double
enum CodingKeys: String, CodingKey {
case minStock = "min_stock"
case locationId = "location_id"
}
// Den Ort immer senden auch null, sonst laesst sich Ueberall" gar nicht
// ausdruecken (Swift laesst nil-Optionals sonst weg).
func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(locationId, forKey: .locationId)
try c.encode(minStock, forKey: .minStock)
}
}
/// Platzhalter-ID fuer Ueberall" in Pickern im JSON ist es schlicht null.
let UEBERALL_ID = "__ueberall__"
let UEBERALL_NAME = "Überall"
/// Auswahleintrag fuer Einheiten.
///
@@ -390,6 +410,8 @@ struct GroupShoppingItem: Codable, Identifiable {
let deficit: Double
let unitName: String
let productCount: Int
/// Wie viele Untergruppen mitgezaehlt werden (0 = keine).
var subgroupCount: Int? = nil
var id: Int { groupId }
@@ -399,6 +421,7 @@ struct GroupShoppingItem: Codable, Identifiable {
case minStock = "min_stock"
case unitName = "unit_name"
case productCount = "product_count"
case subgroupCount = "subgroup_count"
}
}
@@ -637,20 +660,86 @@ struct SettingEntry: Codable {
}
/// Gruppe: fasst Bestaende mehrerer Marken zusammen (z.B. "Mehl").
///
/// Gruppen bilden einen Graphen, keinen Baum: eine Gruppe darf unter MEHREREN
/// Obergruppen haengen (Grillwurst" unter Wurst" UND unter Grillgut").
/// Deshalb ``parentIds`` als Liste und deshalb konformiert GroupItem NICHT zu
/// ``TreeItem``, das genau einen Elternteil kennt.
struct GroupItem: Codable, Identifiable, Hashable {
let id: Int
let name: String
// Mindestbestand-Infos (nur aus /groups befuellt; Picker brauchen sie nicht).
var minStock: Double? = nil
var minStockUnitId: Int? = nil
var minStockUnitName: String? = nil
var minStockUnitFactor: Double? = nil
var kind: String? = nil
var packageSize: Double? = nil
var packageLabel: String? = nil
var minStockInPackages: Bool? = nil
var stock: Double? = nil
var locationMinStocks: [LocationMinStock]? = nil
var parentIds: [Int]? = nil
var childIds: [Int]? = nil
/// Artikel inkl. Untergruppen; ``directProductCount`` nur die eigenen.
var productCount: Int? = nil
var directProductCount: Int? = nil
enum CodingKeys: String, CodingKey {
case id, name, stock
case id, name, stock, kind
case minStock = "min_stock"
case minStockUnitId = "min_stock_unit_id"
case minStockUnitName = "min_stock_unit_name"
case minStockUnitFactor = "min_stock_unit_factor"
case packageSize = "package_size"
case packageLabel = "package_label"
case minStockInPackages = "min_stock_in_packages"
case locationMinStocks = "location_min_stocks"
case parentIds = "parent_ids"
case childIds = "child_ids"
case productCount = "product_count"
case directProductCount = "direct_product_count"
}
/// Basiseinheiten je Erfassungseinheit zum Umrechnen der Mindestbestaende,
/// die in Basiseinheiten gespeichert sind.
var minFaktor: Double {
if minStockInPackages == true, let s = packageSize, s > 0 { return s }
return minStockUnitFactor ?? 1
}
/// Beschriftung der Erfassungseinheit (Glas", Gramm", ).
var minEinheit: String {
if minStockInPackages == true, (packageSize ?? 0) > 0 {
return packageLabel ?? "Packung"
}
if let n = minStockUnitName { return n }
switch kind {
case "weight": return "g"
case "volume": return "ml"
case "count": return "Stück"
default: return ""
}
}
}
/// Gruppe aendern. Nur mitgeschickte Felder wertet das Backend aus deshalb
/// KEIN eigener Encoder: der synthetisierte laesst nil-Optionals weg
/// (encodeIfPresent). Ein handgeschriebener wuerde ``"name": null`` senden und
/// damit den Namen loeschen.
struct GroupUpdateRequest: Codable {
var name: String? = nil
var minStock: Double? = nil
var minStockUnitId: Int? = nil
var minStockInPackages: Bool? = nil
var parentIds: [Int]? = nil
enum CodingKeys: String, CodingKey {
case name
case minStock = "min_stock"
case minStockUnitId = "min_stock_unit_id"
case minStockInPackages = "min_stock_in_packages"
case parentIds = "parent_ids"
}
}
@@ -1283,10 +1372,13 @@ struct NewGroupRequest: Codable {
let name: String
let minStock: Double?
let minStockUnitId: Int?
/// Obergruppen (mehrere moeglich) optional, Standard: keine.
var parentIds: [Int]? = nil
enum CodingKeys: String, CodingKey {
case name
case minStock = "min_stock"
case minStockUnitId = "min_stock_unit_id"
case parentIds = "parent_ids"
}
}

View File

@@ -31,7 +31,6 @@ struct ProductDetailView: View {
@State private var shopId: Int?
@State private var productUrl = ""
// Gesamt-Mindestbestand (nur Gegenstände; Menge in Stück/Artikeleinheit).
@State private var minStock = ""
@State private var shops: [ShopItem] = []
@State private var locations: [StorageLocation] = []
@State private var fieldDefs: [FieldDefinition] = []
@@ -63,8 +62,6 @@ struct ProductDetailView: View {
if packageSize != (current.packageSize.map { formatAmount($0) } ?? "") { return true }
if packageLabel != (current.packageLabel ?? "") { return true }
if datePrecision != (current.datePrecision == "month" ? "month" : "day") { return true }
// Mindestbestand gibt es bei Lebensmitteln und Verbrauchsgegenständen.
if minStock != minStockFeld { return true }
}
if current.isObject {
if objMode != currentObjMode { return true }
@@ -197,26 +194,31 @@ struct ProductDetailView: View {
}
}
// Mindestbestand bei Lebensmitteln UND Verbrauchsgegenständen (Charge)
// in der Anzeigeeinheit (z.B. Gramm/Milliliter), nicht in Packungen.
// Mindestbestand bei Lebensmitteln UND Verbrauchsgegenständen (Charge).
// Menge je Lagerort und Einzelstücke haben keinen Mindestbestand.
if current.foodLike {
Section("Mindestbestand") {
QuantityField(label: "Gesamt (\(current.unitName))", text: $minStock)
}
}
//
// Es gibt kein separates Gesamt"-Feld mehr: der Bedarf hängt immer
// an einem Ort, und Überall" ist einer davon (der oberste).
if current.foodLike {
Section {
NavigationLink {
ProductLocationMinView(product: current) { await reload() }
} label: {
let n = (current.locationMinStocks ?? []).count
Label(n > 0 ? "Mindestbestand je Lagerort (\(n))" : "Mindestbestand je Lagerort",
systemImage: "mappin.and.ellipse")
let zeilen = current.locationMinStocks ?? []
let ueberall = zeilen.first { $0.locationId == nil }
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Mindestbestand")
Text(minStockUntertitel(zeilen: zeilen, ueberall: ueberall))
.font(.caption).foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "mappin.and.ellipse")
}
}
} footer: {
Text("Bedarf je Lagerort (z.B. Ferienhaus, Zuhause), zusätzlich zum Gesamt-Mindestbestand.")
Text("„Überall“ heißt: egal wo, Hauptsache im Haus Käufe für einen "
+ "Lagerort decken das mit ab.")
}
}
@@ -329,10 +331,17 @@ struct ProductDetailView: View {
}
}
/// Gesamt-Mindestbestand in der Anzeigeeinheit (Gramm/Milliliter/Stück) aus
/// den Basiseinheiten umgerechnet, unabhängig davon, wie er erfasst wurde.
private var minStockFeld: String {
current.minStock.map { formatAmount($0 / max(current.unitFactor, 1)) } ?? ""
/// Kurzfassung der hinterlegten Mindestbestände für die Übersichtszeile.
private func minStockUntertitel(zeilen: [LocationMinStock], ueberall: LocationMinStock?) -> String {
if zeilen.isEmpty { return "keiner hinterlegt" }
let faktor = max(current.unitFactor, 1)
var teile: [String] = []
if let u = ueberall {
teile.append("Überall \(formatAmount(u.minStock / faktor)) \(current.unitName)")
}
let orte = zeilen.count - (ueberall == nil ? 0 : 1)
if orte > 0 { teile.append("\(orte) \(orte == 1 ? "Lagerort" : "Lagerorte")") }
return teile.joined(separator: " · ")
}
/// Aktuelle Verwaltungsart des geladenen Artikels.
@@ -350,7 +359,6 @@ struct ProductDetailView: View {
categoryId = current.categoryId
shopId = current.shopId
productUrl = current.productUrl ?? ""
minStock = minStockFeld
objMode = currentObjMode
fieldValues = Self.stringValues(current.fieldValues)
}
@@ -411,9 +419,6 @@ struct ProductDetailView: View {
busy = true
defer { busy = false }
do {
// Mindestbestand in der Anzeigeeinheit erfasst Basiseinheiten.
let minBase = Double(minStock.replacingOccurrences(of: ",", with: "."))
.map { $0 * current.unitFactor }
if current.foodLike {
// Lebensmittel ODER Verbrauchsgegenstand: als Charge mit Einheit/
// Packung/MHD/Gruppe. Verbrauchsgegenstände (isObject) tragen
@@ -427,11 +432,9 @@ struct ProductDetailView: View {
groupId: groupId,
categoryId: categoryId
)
// Mindestbestand für Lebensmittel UND Verbrauchsgegenstände.
req.minStock = minBase
req.minStockInPackages = false
req.minStockUnitId = nil
req.sendMinStock = true
// Der Mindestbestand wird in ProductLocationMinView gepflegt und
// hier bewusst NICHT mitgeschickt sonst überschriebe das
// Stammdaten-Speichern die dort gesetzte Überall"-Zeile.
if current.isObject {
var fv: [String: String?] = [:]
for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" }
@@ -660,22 +663,21 @@ struct ProductLocationMinView: View {
@State private var busy = false
@State private var error: String?
// Speicherung in Artikeleinheiten, Anzeige in der Anzeigeeinheit (Gramm/ml/Stück).
private var artToDisp: Double {
let disp = product.unitFactor == 0 ? 1 : product.unitFactor
return product.articleUnitFactor / disp
}
// Gespeichert wird in Basiseinheiten, erfasst in der Anzeigeeinheit (g/ml/Stück).
private var dispFaktor: Double { product.unitFactor == 0 ? 1 : product.unitFactor }
var body: some View {
Form {
Section {
Text("Bedarf je Lagerort (z.B. Ferienhaus, Zuhause). Menge in \(product.unitName).")
Text("Was soll wo immer da sein? „Überall“ heißt: egal wo, Hauptsache "
+ "im Haus Käufe für einen Lagerort decken das mit ab. "
+ "Menge in \(product.unitName).")
.font(.caption).foregroundStyle(.secondary)
}
ForEach($rows) { $row in
HStack {
Picker("Lagerort", selection: $row.locationId) {
Text(" wählen ").tag(String?.none)
Picker("Ort", selection: $row.locationId) {
Text(UEBERALL_NAME).tag(String?.some(UEBERALL_ID))
ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) }
}
TextField("Menge", text: $row.amount)
@@ -691,15 +693,15 @@ struct ProductLocationMinView: View {
}
}
Button {
rows.append(MinRow(locationId: nil, amount: ""))
rows.append(MinRow(locationId: UEBERALL_ID, amount: ""))
} label: {
Label("Lagerort hinzufügen", systemImage: "plus")
Label("Mindestbestand hinzufügen", systemImage: "plus")
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
}
.navigationTitle("Bedarf je Lagerort")
.navigationTitle("Mindestbestand")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
@@ -712,8 +714,10 @@ struct ProductLocationMinView: View {
private func load() async {
locations = (try? await APIClient.shared.locations()) ?? []
// Überall" ist der Ort nil im Picker braucht es einen Platzhalter.
rows = (product.locationMinStocks ?? []).map {
MinRow(locationId: $0.locationId, amount: formatAmount($0.minStock * artToDisp))
MinRow(locationId: $0.locationId ?? UEBERALL_ID,
amount: formatAmount($0.minStock / dispFaktor))
}
}
@@ -727,8 +731,10 @@ struct ProductLocationMinView: View {
let wert = Double(row.amount.replacingOccurrences(of: ",", with: ".")) ?? 0
if wert > 0 {
gesehen.insert(loc)
// Eingabe in Anzeigeeinheit Artikeleinheiten (so gespeichert).
list.append(LocationMinStockIn(locationId: loc, minStock: wert / artToDisp))
// Eingabe in Anzeigeeinheit Basiseinheiten (so gespeichert).
list.append(LocationMinStockIn(
locationId: loc == UEBERALL_ID ? nil : loc,
minStock: wert * dispFaktor))
}
}
do {

View File

@@ -2,5 +2,9 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:vorrania-ch.scarriffle.com</string>
</array>
</dict>
</plist>

View File

@@ -11,6 +11,7 @@
0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */; };
17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */; };
25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */; };
34787DA16A1AB035BF129BB7 /* GroupViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ED41226394E7F31F37EC3B8 /* GroupViews.swift */; };
38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */; };
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F1B2DE567B62169E84426 /* APIClient.swift */; };
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF19A735AC17451B57BF5BB /* ScannerView.swift */; };
@@ -51,6 +52,7 @@
0365A16FEAE3F2BEC321E68A /* CheckInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInView.swift; sourceTree = "<group>"; };
090485CC54558EB4433376A9 /* DashboardCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardCards.swift; sourceTree = "<group>"; };
099CCD94907BE0179B7D5742 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
0ED41226394E7F31F37EC3B8 /* GroupViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupViews.swift; sourceTree = "<group>"; };
121D44B9990DA931A9CD5847 /* ServerProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerProfiles.swift; sourceTree = "<group>"; };
163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationScheduler.swift; sourceTree = "<group>"; };
1847E65D41ACEDD01CD108BC /* ServerFetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerFetch.swift; sourceTree = "<group>"; };
@@ -61,7 +63,7 @@
3731608B8D48960DC98912BC /* NotificationSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettings.swift; sourceTree = "<group>"; };
378F1B2DE567B62169E84426 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
4741D0E95875919C921945CF /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
4BF4E4F5524B15728CEEE116 /* Vorrania.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Vorrania.app; sourceTree = BUILT_PRODUCTS_DIR; };
4BF4E4F5524B15728CEEE116 /* Vorrania.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Vorrania.app; sourceTree = BUILT_PRODUCTS_DIR; };
5312AFE88B7284401AA7B0A3 /* ProductPhotoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPhotoView.swift; sourceTree = "<group>"; };
660ACEFA22BC77D52FBE736A /* AppDashboards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDashboards.swift; sourceTree = "<group>"; };
69B59B9F69BC30451D45A3C7 /* ItemViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViews.swift; sourceTree = "<group>"; };
@@ -105,6 +107,7 @@
E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */,
717C8EB336170526F5F3E695 /* DateScanView.swift */,
AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */,
0ED41226394E7F31F37EC3B8 /* GroupViews.swift */,
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */,
00288DFA409BAD753AA4CA5D /* ItemListView.swift */,
69B59B9F69BC30451D45A3C7 /* ItemViews.swift */,
@@ -234,6 +237,7 @@
926451E72FD13708C5DD657B /* DashboardView.swift in Sources */,
ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */,
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */,
34787DA16A1AB035BF129BB7 /* GroupViews.swift in Sources */,
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */,
749139BEFF35F9DAFFE87C3B /* ItemListView.swift in Sources */,
56A5DFAC86DF972B1481A691 /* ItemViews.swift in Sources */,
@@ -329,7 +333,6 @@
DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Sources/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -337,10 +340,6 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.vorrania;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_VERSION = 5.9;
TARGETED_DEVICE_FAMILY = "1,2";
};
@@ -356,7 +355,6 @@
DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Sources/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -364,10 +362,6 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.vorrania;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_VERSION = 5.9;
TARGETED_DEVICE_FAMILY = "1,2";
};