From 9a11e3cfc63f8f418c20fd1711d1e0854d32ae36 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 26 Jul 2026 16:10:14 +0200 Subject: [PATCH] =?UTF-8?q?iOS:=20Lagerorte=20umh=C3=A4ngen,=20Unterkatego?= =?UTF-8?q?rien=20anlegen,=20eigene=20Felder=20verwalten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lagerorte: LocationEditor hängt bestehende Orte jetzt um (Elternort auch beim Bearbeiten, eigene Unterorte ausgeschlossen); Client updateLocation + LocationUpdateRequest (parent_id wird immer gesendet, auch null). - Kategorien: beim Anlegen optionale Oberkategorie wählbar (Unterkategorie); mit Eltern erbt sie deren Verwaltungsart, sonst wird sie gewählt. - Eigene Felder: neue CategoryFieldsView + FieldEditor (anlegen/bearbeiten/ löschen von Feldern je Kategorie), erreichbar aus dem Kategorie-Editor; Client ownFieldDefinitions/create/update/deleteFieldDefinition + Models. Co-Authored-By: Claude Opus 4.8 --- ios/Sources/APIClient.swift | 29 +++ ios/Sources/MasterDataViews.swift | 349 +++++++++++++++++++++++++++--- ios/Sources/Models.swift | 48 ++++ 3 files changed, 394 insertions(+), 32 deletions(-) diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index c3a2485..7216272 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -280,6 +280,29 @@ actor APIClient { try await send(try makeRequest("/categories/\(categoryId)/fields"), as: [FieldDefinition].self) } + /// Nur die *eigenen* Felder einer Kategorie (ohne vererbte) – fuer die + /// Feldverwaltung, wo man sie anlegt, bearbeitet und loescht. + func ownFieldDefinitions(categoryId: Int) async throws -> [FieldDefinition] { + try await send(try makeRequest("/field-definitions?category_id=\(categoryId)"), + as: [FieldDefinition].self) + } + + func createFieldDefinition(_ payload: FieldDefinitionCreate) async throws -> FieldDefinition { + var request = try makeRequest("/field-definitions", method: "POST") + try jsonBody(&request, payload) + return try await send(request, as: FieldDefinition.self) + } + + func updateFieldDefinition(id: Int, _ payload: FieldDefinitionUpdate) async throws -> FieldDefinition { + var request = try makeRequest("/field-definitions/\(id)", method: "PATCH") + try jsonBody(&request, payload) + return try await send(request, as: FieldDefinition.self) + } + + func deleteFieldDefinition(id: Int) async throws { + try await sendNoContent(try makeRequest("/field-definitions/\(id)", method: "DELETE")) + } + /// Menge eines Gegenstands an einem Lagerort erhoehen. func objectCheckIn(_ payload: ObjectCheckInRequest) async throws -> StockResponse { var request = try makeRequest("/stock/checkin", method: "POST") @@ -358,6 +381,12 @@ actor APIClient { return try await send(request, as: StorageLocation.self) } + func updateLocation(id: Int, _ payload: LocationUpdateRequest) async throws -> StorageLocation { + var request = try makeRequest("/locations/\(id)", method: "PATCH") + try jsonBody(&request, payload) + return try await send(request, as: StorageLocation.self) + } + func deleteLocation(id: Int) async throws { try await sendNoContent(try makeRequest("/locations/\(id)", method: "DELETE")) } diff --git a/ios/Sources/MasterDataViews.swift b/ios/Sources/MasterDataViews.swift index 38eda76..fc73c03 100644 --- a/ios/Sources/MasterDataViews.swift +++ b/ios/Sources/MasterDataViews.swift @@ -430,9 +430,9 @@ struct LocationsView: View { } } -/// Lagerort anlegen (mit optionalem Elternort, um die Hierarchie aufzubauen) -/// oder umbenennen. Umhängen unterstützt der Server nicht – beim Bearbeiten -/// gibt es deshalb nur den Namen. +/// Lagerort anlegen oder bearbeiten: Name **und** übergeordneter Ort. So lässt +/// sich die Hierarchie auch nachträglich ändern (umhängen). Beim Bearbeiten sind +/// der Ort selbst und seine Unterorte als Ziel ausgeschlossen (kein Ring). struct LocationEditor: View { @Environment(\.dismiss) private var dismiss @@ -453,31 +453,33 @@ struct LocationEditor: View { _parentId = State(initialValue: item?.parentId) } + private var parentOptions: [StorageLocation] { + guard let item else { return all } + let verboten = LocationEditor.descendants(of: item.id, in: all).union([item.id]) + return all.filter { !verboten.contains($0.id) } + } + var body: some View { NavigationStack { Form { Section("Name") { TextField("z. B. Keller", text: $name) } - // Elternort nur beim Anlegen: der Server kann bestehende Orte - // nicht umhängen. - if item == nil { - Section { - Picker("Übergeordnet", selection: $parentId) { - Text("– oberste Ebene –").tag(Int?.none) - ForEach(all) { loc in - Text(LocationEditor.pfad(loc, in: all)).tag(Int?.some(loc.id)) - } + Section { + Picker("Übergeordnet", selection: $parentId) { + Text("– oberste Ebene –").tag(Int?.none) + ForEach(parentOptions) { loc in + Text(LocationEditor.pfad(loc, in: all)).tag(Int?.some(loc.id)) } - } header: { - Text("Übergeordneter Lagerort (optional)") } + } header: { + Text("Übergeordneter Lagerort (optional)") } if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } } - .navigationTitle(item == nil ? "Lagerort anlegen" : "Lagerort umbenennen") + .navigationTitle(item == nil ? "Lagerort anlegen" : "Lagerort bearbeiten") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { @@ -497,7 +499,8 @@ struct LocationEditor: View { let n = name.trimmingCharacters(in: .whitespaces) do { if let item { - _ = try await APIClient.shared.renameLocation(id: item.id, name: n) + _ = try await APIClient.shared.updateLocation( + id: item.id, LocationUpdateRequest(name: n, parentId: parentId)) } else { _ = try await APIClient.shared.createLocation( NewLocationRequest(name: n, parentId: parentId)) @@ -509,6 +512,18 @@ struct LocationEditor: View { } } + /// Alle Unterorte (rekursiv) – als Ziel beim Umhängen ausgeschlossen. + private static func descendants(of id: Int, in all: [StorageLocation]) -> Set { + var result: Set = [] + var stack = [id] + while let cur = stack.popLast() { + for kid in all where kid.parentId == cur { + if result.insert(kid.id).inserted { stack.append(kid.id) } + } + } + return result + } + /// "Keller → Regal" – der Pfad macht gleiche Namen im flachen Picker /// unterscheidbar. private static func pfad(_ loc: StorageLocation, in all: [StorageLocation]) -> String { @@ -754,8 +769,8 @@ struct CategoriesView: View { .padding(.horizontal) .padding(.vertical, 8) }, - editor: { item, _, done in - CategoryEditor(item: item, done: done) + editor: { item, all, done in + CategoryEditor(item: item, all: all, done: done) } ) } @@ -765,44 +780,83 @@ struct CategoriesView: View { } } -/// Kategorie anlegen oder bearbeiten: Name und Verwaltungsart (Lebensmittel vs. -/// Gegenstand). Neue Kategorien entstehen auf oberster Ebene; Verschachteln -/// bleibt der Web-Oberfläche vorbehalten. +/// Kategorie anlegen oder bearbeiten: Name, Verwaltungsart und – beim Anlegen – +/// eine optionale Oberkategorie (Unterkategorie). Bei bestehenden Kategorien +/// führt ein Link zur Verwaltung der eigenen Felder. struct CategoryEditor: View { @Environment(\.dismiss) private var dismiss let item: CategoryItem? + let all: [CategoryItem] let done: () -> Void @State private var name: String @State private var tracking: String + @State private var parentId: Int? // nur beim Anlegen @State private var error: String? @State private var busy = false - init(item: CategoryItem?, done: @escaping () -> Void) { + init(item: CategoryItem?, all: [CategoryItem], done: @escaping () -> Void) { self.item = item + self.all = all self.done = done _name = State(initialValue: item?.name ?? "") _tracking = State(initialValue: item?.tracking ?? "object") + _parentId = State(initialValue: nil) } + // Mit gewählter Oberkategorie erbt die neue Kategorie deren Art – dann ist + // die Art-Auswahl gegenstandslos. + private var erbtVonEltern: Bool { item == nil && parentId != nil } + var body: some View { NavigationStack { Form { Section("Name") { TextField("z. B. Elektronik", text: $name) } - Section { - Picker("Art", selection: $tracking) { - Text("Lebensmittel").tag("food") - Text("Gegenstand").tag("object") + + if item == nil { + Section { + Picker("Übergeordnet", selection: $parentId) { + Text("– oberste Ebene –").tag(Int?.none) + ForEach(all) { c in + Text(CategoryEditor.pfad(c, in: all)).tag(Int?.some(c.id)) + } + } + } header: { + Text("Übergeordnete Kategorie (optional)") + } footer: { + Text(erbtVonEltern + ? "Erbt die Verwaltungsart der Oberkategorie." + : "Ohne Oberkategorie unten die Verwaltungsart wählen.") } - .pickerStyle(.segmented) - } header: { - Text("Verwaltungsart") - } footer: { - Text("Lebensmittel: Chargen mit Mindesthaltbarkeit. Gegenstand: Menge je Lagerort bzw. Einzelstücke. Unterkategorien erben die Art.") } + + if !erbtVonEltern { + Section { + Picker("Art", selection: $tracking) { + Text("Lebensmittel").tag("food") + Text("Gegenstand").tag("object") + } + .pickerStyle(.segmented) + } header: { + Text("Verwaltungsart") + } footer: { + Text("Lebensmittel: Chargen mit Mindesthaltbarkeit. Gegenstand: Menge je Lagerort bzw. Einzelstücke. Unterkategorien erben die Art.") + } + } + + if let item { + Section { + NavigationLink { + CategoryFieldsView(categoryId: item.id, categoryName: item.name) + } label: { + Label("Eigene Felder verwalten", systemImage: "tag") + } + } + } + if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } @@ -830,8 +884,239 @@ struct CategoryEditor: View { _ = try await APIClient.shared.updateCategory( id: item.id, CategoryUpdateRequest(name: n, tracking: tracking)) } else { + // Mit Oberkategorie die Art erben (tracking = nil), sonst die + // gewählte Art. _ = try await APIClient.shared.createCategory( - NewCategoryRequest(name: n, parentId: nil, tracking: tracking)) + NewCategoryRequest(name: n, parentId: parentId, + tracking: parentId == nil ? tracking : nil)) + } + done() + dismiss() + } catch { + self.error = error.localizedDescription + } + } + + private static func pfad(_ c: CategoryItem, in all: [CategoryItem]) -> String { + let byId = Dictionary(uniqueKeysWithValues: all.map { ($0.id, $0) }) + var teile = [c.name] + var pid = c.parentId + while let cur = pid, let parent = byId[cur] { + teile.insert(parent.name, at: 0) + pid = parent.parentId + } + return teile.joined(separator: " → ") + } +} + +// MARK: - Eigene Felder je Kategorie + +/// Eigene Felder einer Kategorie verwalten (anlegen, bearbeiten, löschen). Zeigt +/// nur die *eigenen* Felder – geerbte werden in der Oberkategorie gepflegt. +struct CategoryFieldsView: View { + let categoryId: Int + let categoryName: String + + @State private var fields: [FieldDefinition] = [] + @State private var busy = true + @State private var error: String? + @State private var editing: FieldDefinition? + @State private var addShown = false + @State private var pendingDeletion: FieldDefinition? + + var body: some View { + List { + if let error { + Section { Text(error).foregroundStyle(.red).font(.callout) } + } + ForEach(fields) { f in + Button { editing = f } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(f.label).foregroundStyle(.primary) + Text(CategoryFieldsView.untertitel(f)) + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + if f.required { + Text("Pflicht").font(.caption2).foregroundStyle(.orange) + } + } + } + .swipeActions(edge: .trailing) { + Button("Löschen", role: .destructive) { pendingDeletion = f } + } + } + if fields.isEmpty && !busy { + Text("Noch keine eigenen Felder.").foregroundStyle(.secondary) + } + } + .navigationTitle("Felder: \(categoryName)") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Hinzufügen", systemImage: "plus") { addShown = true } + } + } + .sheet(isPresented: $addShown) { + FieldEditor(categoryId: categoryId, field: nil) { Task { await reload() } } + } + .sheet(item: $editing) { f in + FieldEditor(categoryId: categoryId, field: f) { Task { await reload() } } + } + .confirmationDialog( + pendingDeletion.map { "„\($0.label)“ löschen?" } ?? "", + isPresented: Binding(get: { pendingDeletion != nil }, + set: { if !$0 { pendingDeletion = nil } }), + titleVisibility: .visible + ) { + Button("Löschen", role: .destructive) { + if let f = pendingDeletion { Task { await remove(f) } } + pendingDeletion = nil + } + Button("Abbrechen", role: .cancel) { pendingDeletion = nil } + } message: { + Text("Die zu diesem Feld erfassten Werte gehen an allen Artikeln verloren.") + } + .refreshable { await reload() } + .task { await reload() } + } + + private static func untertitel(_ f: FieldDefinition) -> String { + var teile = [FieldEditor.typLabel(f.fieldType)] + if f.fieldType == "number", let u = f.unit, !u.isEmpty { teile.append(u) } + if f.fieldType == "select", !f.options.isEmpty { + teile.append(f.options.joined(separator: ", ")) + } + return teile.joined(separator: " · ") + } + + private func reload() async { + busy = true + defer { busy = false } + do { + fields = try await APIClient.shared.ownFieldDefinitions(categoryId: categoryId) + error = nil + } catch { + self.error = error.localizedDescription + } + } + + private func remove(_ f: FieldDefinition) async { + do { + try await APIClient.shared.deleteFieldDefinition(id: f.id) + error = nil + await reload() + } catch { + self.error = error.localizedDescription + } + } +} + +/// Eigenes Feld anlegen oder bearbeiten: Name, Typ, (Einheit bei Zahl, +/// Auswahlmöglichkeiten bei Auswahlliste) und Pflicht. +struct FieldEditor: View { + @Environment(\.dismiss) private var dismiss + + let categoryId: Int + let field: FieldDefinition? + let done: () -> Void + + @State private var label: String + @State private var fieldType: String + @State private var unit: String + @State private var options: String // Komma-getrennt + @State private var required: Bool + @State private var error: String? + @State private var busy = false + + static let typen: [(String, String)] = [ + ("text", "Text (einzeilig)"), + ("textarea", "Text (mehrzeilig)"), + ("number", "Zahl (mit Einheit)"), + ("date", "Datum"), + ("select", "Auswahlliste"), + ("boolean", "Ja/Nein"), + ] + + static func typLabel(_ v: String) -> String { typen.first { $0.0 == v }?.1 ?? v } + + init(categoryId: Int, field: FieldDefinition?, done: @escaping () -> Void) { + self.categoryId = categoryId + self.field = field + self.done = done + _label = State(initialValue: field?.label ?? "") + _fieldType = State(initialValue: field?.fieldType ?? "text") + _unit = State(initialValue: field?.unit ?? "") + _options = State(initialValue: (field?.options ?? []).joined(separator: ", ")) + _required = State(initialValue: field?.required ?? false) + } + + var body: some View { + NavigationStack { + Form { + Section("Feldname") { + TextField("z. B. Kapazität", text: $label) + } + Section("Typ") { + Picker("Typ", selection: $fieldType) { + ForEach(FieldEditor.typen, id: \.0) { Text($0.1).tag($0.0) } + } + } + if fieldType == "number" { + Section("Einheit (optional)") { + TextField("z. B. mAh", text: $unit) + } + } + if fieldType == "select" { + Section { + TextField("z. B. S, M, L, XL", text: $options) + } header: { + Text("Auswahlmöglichkeiten") + } footer: { + Text("Mit Komma trennen.") + } + } + Section { + Toggle("Pflichtfeld", isOn: $required) + } + if let error { + Section { Text(error).foregroundStyle(.red).font(.callout) } + } + } + .navigationTitle(field == nil ? "Feld anlegen" : "Feld bearbeiten") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Abbrechen") { dismiss() } + } + ToolbarItem(placement: .topBarTrailing) { + Button(busy ? "Sichern…" : "Sichern") { Task { await submit() } } + .disabled(busy || label.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + } + } + + private func submit() async { + busy = true + defer { busy = false } + let l = label.trimmingCharacters(in: .whitespaces) + let u = fieldType == "number" ? unit.trimmingCharacters(in: .whitespaces) : "" + let opts = fieldType == "select" + ? options.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + : [] + do { + if let field { + _ = try await APIClient.shared.updateFieldDefinition( + id: field.id, + FieldDefinitionUpdate(label: l, fieldType: fieldType, unit: u, + options: opts, required: required)) + } else { + _ = try await APIClient.shared.createFieldDefinition( + FieldDefinitionCreate(categoryId: categoryId, label: l, fieldType: fieldType, + unit: u, options: opts, required: required)) } done() dismiss() diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index 15addb0..5ca6346 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -854,6 +854,54 @@ struct NewLocationRequest: Codable { } } +/// Lagerort umbenennen und/oder umhängen. `parentId` wird bewusst immer gesendet +/// (auch `null` = oberste Ebene), damit „auf oberste Ebene holen" ankommt. +struct LocationUpdateRequest: Codable { + let name: String + let parentId: Int? + + enum CodingKeys: String, CodingKey { + case name + case parentId = "parent_id" + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(name, forKey: .name) + try c.encode(parentId, forKey: .parentId) // encodet null bei nil + } +} + +/// Eigenes Feld einer Kategorie anlegen. `unit`/`options` werden immer +/// mitgeschickt; der Server ignoriert sie bei unpassendem Typ. +struct FieldDefinitionCreate: Codable { + let categoryId: Int + let label: String + let fieldType: String + let unit: String + let options: [String] + let required: Bool + + enum CodingKeys: String, CodingKey { + case label, unit, options, required + case categoryId = "category_id" + case fieldType = "field_type" + } +} + +struct FieldDefinitionUpdate: Codable { + let label: String + let fieldType: String + let unit: String + let options: [String] + let required: Bool + + enum CodingKeys: String, CodingKey { + case label, unit, options, required + case fieldType = "field_type" + } +} + struct NewUnitRequest: Codable { let name: String let kind: String