import SwiftUI /// Verwaltungs-Tab: alles, was Stammdaten sind, plus die Serverliste. struct AdminTabView: View { @EnvironmentObject private var session: Session @State private var manageShown = false var body: some View { NavigationStack { List { Section("Stammdaten") { NavigationLink { LocationsView() } label: { Label("Lagerorte", systemImage: "mappin.and.ellipse") } NavigationLink { UnitsView() } label: { Label("Einheiten", systemImage: "ruler") } NavigationLink { PackageTypesView() } label: { Label("Gebinde", systemImage: "shippingbox") } NavigationLink { CategoriesView() } label: { Label("Kategorien", systemImage: "folder") } NavigationLink { GroupsView() } label: { Label("Gruppen", systemImage: "square.stack.3d.up") } NavigationLink { ShopsView() } label: { Label("Shops", systemImage: "cart") } } Section("App") { Button { manageShown = true } label: { Label("Server verwalten", systemImage: "server.rack") } } Section { Button("Abmelden", role: .destructive) { session.logout() } } footer: { Text("Benutzer, Einstellungen und Sicherungen bleiben der Web-Oberfläche vorbehalten.") } } .navigationTitle("Verwaltung") .sheet(isPresented: $manageShown) { ServerListView() } } } } // MARK: - Gemeinsamer Bauplan /// Ein Eintrag, wie ihn die Stammdatenliste braucht - unabhaengig davon, ob /// dahinter ein Lagerort, eine Einheit oder ein Gebinde steckt. struct MasterDataItem: Identifiable, Equatable { let id: Int let title: String let subtitle: String /// Eingebautes laesst sich umbenennen, aber nicht loeschen. let isBuiltin: Bool } /// Liste mit Anlegen, Umbenennen und Loeschen. /// /// Alle fuenf Stammdatenbereiche sehen gleich aus und unterscheiden sich nur /// darin, *wie* geladen und geschrieben wird. Genau das wird hier /// hereingereicht, statt die Ansicht fuenfmal zu schreiben. struct MasterDataListView: View { let title: String /// Einzahl fuer Meldungen ("Lagerort"). let singular: String let load: () async throws -> [MasterDataItem] let delete: ((Int) async throws -> Void)? /// Baut den Editor zum Anlegen (`nil`) oder Bearbeiten eines Eintrags. @ViewBuilder let editor: (MasterDataItem?, @escaping () -> Void) -> Editor @State private var items: [MasterDataItem] = [] @State private var busy = true @State private var error: String? @State private var editing: MasterDataItem? @State private var addShown = false @State private var pendingDeletion: MasterDataItem? var body: some View { List { if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } ForEach(items) { item in Button { editing = item } label: { HStack { VStack(alignment: .leading, spacing: 2) { Text(item.title).foregroundStyle(.primary) if !item.subtitle.isEmpty { Text(item.subtitle).font(.caption).foregroundStyle(.secondary) } } Spacer() if item.isBuiltin { Text("eingebaut").font(.caption2).foregroundStyle(.tertiary) } } } .swipeActions(edge: .trailing) { // Eingebautes bekommt gar nicht erst einen Loeschknopf. if delete != nil && !item.isBuiltin { Button("Löschen", role: .destructive) { pendingDeletion = item } } } } if items.isEmpty && !busy { Text("Noch nichts angelegt.").foregroundStyle(.secondary) } } .navigationTitle(title) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Hinzufügen", systemImage: "plus") { addShown = true } } } .sheet(isPresented: $addShown) { editor(nil) { Task { await reload() } } } .sheet(item: $editing) { item in editor(item) { Task { await reload() } } } .confirmationDialog( pendingDeletion.map { "„\($0.title)“ löschen?" } ?? "", isPresented: Binding(get: { pendingDeletion != nil }, set: { if !$0 { pendingDeletion = nil } }), titleVisibility: .visible ) { Button("Löschen", role: .destructive) { if let item = pendingDeletion { Task { await remove(item) } } pendingDeletion = nil } Button("Abbrechen", role: .cancel) { pendingDeletion = nil } } message: { Text("Wird \(singular.lowercased()) noch verwendet, lehnt der Server das Löschen ab.") } .refreshable { await reload() } .task { await reload() } } private func reload() async { busy = true defer { busy = false } do { items = try await load() error = nil } catch { self.error = error.localizedDescription } } private func remove(_ item: MasterDataItem) async { guard let delete else { return } do { try await delete(item.id) error = nil await reload() } catch { // Der Server begruendet die Ablehnung ("wird noch verwendet") - // die Meldung unveraendert zeigen. self.error = error.localizedDescription } } } // MARK: - Aufklappbarer Baum (Kategorien, Lagerorte) /// Ein-/ausklappbare Baumliste für hierarchische Stammdaten. Kümmert sich um /// Baumreihenfolge, Ein-/Ausklappen, Anlegen, Bearbeiten und Löschen. Die /// Besonderheiten (Kopfzeile mit Filter, Zeilen-Hinweis, Editor) kommen von /// außen herein, damit Kategorien und Lagerorte dieselbe Mechanik teilen. struct TreeMasterView: View { let title: String let singular: String let load: () async throws -> [Item] let delete: (Int) async throws -> Void /// Optionaler kleiner Hinweis am Zeilenende (z. B. der Kategorie-Typ). let badge: (Item) -> String? /// Nur Einträge, die das erfüllen, werden gezeigt (z. B. der Typ-Filter). let include: (Item) -> Bool @ViewBuilder let header: () -> Header @ViewBuilder let editor: (_ editing: Item?, _ all: [Item], _ done: @escaping () -> Void) -> Editor @State private var items: [Item] = [] @State private var collapsed: Set = [] @State private var busy = true @State private var error: String? @State private var editing: Item? @State private var addShown = false @State private var pendingDeletion: Item? private struct Node: Identifiable { let item: Item let depth: Int var id: Int { item.id } } private var shown: [Item] { items.filter(include) } /// Baumreihenfolge mit Tiefe. Waisen (Elternteil gefiltert/gelöscht) gelten /// als oberste Ebene, damit nichts verschwindet. private var nodes: [Node] { let list = shown let ids = Set(list.map(\.id)) var result: [Node] = [] func walk(_ n: Item, _ depth: Int) { result.append(Node(item: n, depth: depth)) for child in list.filter({ $0.parentId == n.id }) { walk(child, depth + 1) } } for root in list.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) { walk(root, 0) } return result } private func childCount(_ id: Int) -> Int { shown.filter { $0.parentId == id }.count } private var visible: [Node] { let parent = Dictionary(uniqueKeysWithValues: shown.map { ($0.id, $0.parentId) }) func hidden(_ id: Int) -> Bool { var p = parent[id] ?? nil while let cur = p { if collapsed.contains(cur) { return true } p = parent[cur] ?? nil } return false } return nodes.filter { !hidden($0.item.id) } } var body: some View { VStack(spacing: 0) { header() List { if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } ForEach(visible) { node in row(node) .swipeActions(edge: .trailing) { Button("Löschen", role: .destructive) { pendingDeletion = node.item } } } if shown.isEmpty && !busy { Text("Nichts vorhanden.").foregroundStyle(.secondary) } } } .navigationTitle(title) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Hinzufügen", systemImage: "plus") { addShown = true } } } .sheet(isPresented: $addShown) { editor(nil, items) { Task { await reload() } } } .sheet(item: $editing) { item in editor(item, items) { Task { await reload() } } } .confirmationDialog( pendingDeletion.map { "„\($0.name)“ löschen?" } ?? "", isPresented: Binding(get: { pendingDeletion != nil }, set: { if !$0 { pendingDeletion = nil } }), titleVisibility: .visible ) { Button("Löschen", role: .destructive) { if let item = pendingDeletion { Task { await remove(item) } } pendingDeletion = nil } Button("Abbrechen", role: .cancel) { pendingDeletion = nil } } message: { Text("Wird \(singular.lowercased()) noch verwendet oder hat Untereinträge, entscheidet der Server.") } .refreshable { await reload() } .task { await reload() } } @ViewBuilder private func row(_ node: Node) -> some View { let kinder = childCount(node.item.id) let zu = collapsed.contains(node.item.id) HStack(spacing: 8) { if kinder > 0 { Button { withAnimation(.easeInOut(duration: 0.15)) { toggle(node.item.id) } } label: { Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) .rotationEffect(.degrees(zu ? 0 : 90)) .frame(width: 18) } .buttonStyle(.plain) } else { Color.clear.frame(width: 18, height: 1) } Button { editing = node.item } label: { HStack(spacing: 8) { Text(node.item.name).foregroundStyle(.primary) if zu && kinder > 0 { Text("\(kinder) ausgeblendet").font(.caption2).foregroundStyle(.tertiary) } Spacer() if let hinweis = badge(node.item) { Text(hinweis).font(.caption2).foregroundStyle(.secondary) } } .contentShape(Rectangle()) } .buttonStyle(.plain) } .padding(.leading, CGFloat(node.depth) * 16) } private func toggle(_ id: Int) { if collapsed.contains(id) { collapsed.remove(id) } else { collapsed.insert(id) } } private func reload() async { busy = true defer { busy = false } do { items = try await load() error = nil } catch { self.error = error.localizedDescription } } private func remove(_ item: Item) async { do { try await delete(item.id) error = nil await reload() } catch { self.error = error.localizedDescription } } } /// Kleiner Editor fuer alles, was nur einen Namen hat. struct NameEditor: View { @Environment(\.dismiss) private var dismiss let title: String let label: String let initial: String let save: (String) async throws -> Void let done: () -> Void @State private var name: String @State private var error: String? @State private var busy = false init(title: String, label: String = "Name", initial: String = "", save: @escaping (String) async throws -> Void, done: @escaping () -> Void) { self.title = title self.label = label self.initial = initial self.save = save self.done = done _name = State(initialValue: initial) } var body: some View { NavigationStack { Form { Section(label) { TextField(label, text: $name) } if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } } .navigationTitle(title) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } ToolbarItem(placement: .topBarTrailing) { Button(busy ? "Sichern…" : "Sichern") { Task { await submit() } } .disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty) } } } } private func submit() async { busy = true defer { busy = false } do { try await save(name.trimmingCharacters(in: .whitespaces)) done() dismiss() } catch { self.error = error.localizedDescription } } } // MARK: - Die fuenf Bereiche struct LocationsView: View { var body: some View { TreeMasterView( title: "Lagerorte", singular: "Der Lagerort", load: { try await APIClient.shared.locations() }, delete: { try await APIClient.shared.deleteLocation(id: $0) }, badge: { _ in nil }, include: { _ in true }, header: { EmptyView() }, editor: { item, all, done in LocationEditor(item: item, all: all, done: done) } ) } } /// 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 let item: StorageLocation? let all: [StorageLocation] let done: () -> Void @State private var name: String @State private var parentId: Int? @State private var error: String? @State private var busy = false init(item: StorageLocation?, all: [StorageLocation], done: @escaping () -> Void) { self.item = item self.all = all self.done = done _name = State(initialValue: item?.name ?? "") _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) } 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)") } if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } } .navigationTitle(item == nil ? "Lagerort anlegen" : "Lagerort bearbeiten") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } ToolbarItem(placement: .topBarTrailing) { Button(busy ? "Sichern…" : "Sichern") { Task { await submit() } } .disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty) } } } } private func submit() async { busy = true defer { busy = false } let n = name.trimmingCharacters(in: .whitespaces) do { if let item { _ = try await APIClient.shared.updateLocation( id: item.id, LocationUpdateRequest(name: n, parentId: parentId)) } else { _ = try await APIClient.shared.createLocation( NewLocationRequest(name: n, parentId: parentId)) } done() dismiss() } catch { self.error = error.localizedDescription } } /// 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 { let byId = Dictionary(uniqueKeysWithValues: all.map { ($0.id, $0) }) var teile = [loc.name] var pid = loc.parentId while let cur = pid, let parent = byId[cur] { teile.insert(parent.name, at: 0) pid = parent.parentId } return teile.joined(separator: " → ") } } struct UnitsView: View { var body: some View { MasterDataListView( title: "Einheiten", singular: "Die Einheit", load: { try await APIClient.shared.units().map { MasterDataItem(id: $0.id, title: $0.name, subtitle: "\(UnitsView.artText($0.kind)) · Faktor \(formatAmount($0.factor))", isBuiltin: $0.isBuiltin) } }, delete: { try await APIClient.shared.deleteUnit(id: $0) } ) { item, done in if let item { NameEditor( title: "Einheit umbenennen", initial: item.title, save: { _ = try await APIClient.shared.renameUnit(id: item.id, name: $0) }, done: done ) } else { NewUnitEditor(done: done) } } } private static func artText(_ kind: String) -> String { switch kind { case "count": return "Anzahl" case "weight": return "Gewicht" case "volume": return "Volumen" default: return kind } } } /// Einheiten brauchen beim Anlegen Art und Faktor - danach stehen beide fest, /// weil sie in bereits umgerechneten Bestaenden stecken. struct NewUnitEditor: View { @Environment(\.dismiss) private var dismiss let done: () -> Void @State private var name = "" @State private var kind = "weight" @State private var factor = "1" @State private var error: String? @State private var busy = false private var faktorWert: Double? { Double(factor.replacingOccurrences(of: ",", with: ".")) } var body: some View { NavigationStack { Form { Section("Name") { TextField("z. B. Beutel", text: $name) } Section("Art") { Picker("Art", selection: $kind) { Text("Anzahl").tag("count") Text("Gewicht").tag("weight") Text("Volumen").tag("volume") } .pickerStyle(.segmented) } Section { TextField("1", text: $factor).keyboardType(.decimalPad) } header: { Text("Faktor") } footer: { Text("Wie viele Basiseinheiten ergibt eine Einheit? Ein Kilogramm hat den Faktor 1000, weil die Basiseinheit Gramm ist.") } if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } } .navigationTitle("Einheit anlegen") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } ToolbarItem(placement: .topBarTrailing) { Button("Sichern") { Task { await submit() } } .disabled(busy || name.isEmpty || (faktorWert ?? 0) <= 0) } } } } private func submit() async { guard let wert = faktorWert else { return } busy = true defer { busy = false } do { _ = try await APIClient.shared.createUnit( NewUnitRequest(name: name.trimmingCharacters(in: .whitespaces), kind: kind, factor: wert)) done() dismiss() } catch { self.error = error.localizedDescription } } } struct PackageTypesView: View { var body: some View { MasterDataListView( title: "Gebinde", singular: "Das Gebinde", load: { try await APIClient.shared.packageTypes().map { MasterDataItem(id: $0.id, title: $0.singular, subtitle: "Mehrzahl: \($0.plural)", isBuiltin: $0.isBuiltin) } }, delete: { try await APIClient.shared.deletePackageType(id: $0) } ) { item, done in PackageTypeEditor(id: item?.id, singular: item?.title ?? "", plural: item.map { PackageTypesView.plural(from: $0.subtitle) } ?? "", done: done) } } /// Der Untertitel traegt die Mehrzahl - hier wieder heraus. private static func plural(from subtitle: String) -> String { subtitle.replacingOccurrences(of: "Mehrzahl: ", with: "") } } /// Gebinde haben Einzahl **und** Mehrzahl (siehe Commit "Gebinde verwalten"). struct PackageTypeEditor: View { @Environment(\.dismiss) private var dismiss let id: Int? let done: () -> Void @State private var singular: String @State private var plural: String @State private var error: String? @State private var busy = false init(id: Int?, singular: String, plural: String, done: @escaping () -> Void) { self.id = id self.done = done _singular = State(initialValue: singular) _plural = State(initialValue: plural) } var body: some View { NavigationStack { Form { Section("Einzahl") { TextField("z. B. Glas", text: $singular) } Section { TextField(singular.isEmpty ? "z. B. Gläser" : singular, text: $plural) } header: { Text("Mehrzahl") } footer: { Text("Ohne Angabe wird die Einzahl verwendet.") } if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } } .navigationTitle(id == nil ? "Gebinde anlegen" : "Gebinde bearbeiten") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } ToolbarItem(placement: .topBarTrailing) { Button("Sichern") { Task { await submit() } } .disabled(busy || singular.trimmingCharacters(in: .whitespaces).isEmpty) } } } } private func submit() async { busy = true defer { busy = false } let einzahl = singular.trimmingCharacters(in: .whitespaces) let mehrzahl = plural.trimmingCharacters(in: .whitespaces).isEmpty ? einzahl : plural.trimmingCharacters(in: .whitespaces) do { if let id { _ = try await APIClient.shared.updatePackageType( id: id, PackageTypeUpdateRequest(singular: einzahl, plural: mehrzahl)) } else { _ = try await APIClient.shared.createPackageType( NewPackageTypeRequest(singular: einzahl, plural: mehrzahl)) } done() dismiss() } catch { self.error = error.localizedDescription } } } /// Kategorien als ein-/ausklappbarer Baum (wie im Web) mit Typ-Filter /// Lebensmittel/Gegenstände. Anlegen und Bearbeiten wählen die Verwaltungsart. struct CategoriesView: View { // "" = alle, sonst "food"/"object". @State private var typFilter = "" var body: some View { TreeMasterView( title: "Kategorien", singular: "Die Kategorie", load: { try await APIClient.shared.categories() }, delete: { try await APIClient.shared.deleteCategory(id: $0) }, // Typ nur in der „Alle"-Ansicht zeigen – gefiltert wäre er redundant. badge: { typFilter == "" ? CategoriesView.typLabel($0.tracking) : nil }, include: { typFilter == "" || ($0.tracking ?? "food") == typFilter }, header: { Picker("Typ", selection: $typFilter) { Text("Alle").tag("") Text("Lebensmittel").tag("food") Text("Gegenstände").tag("object") } .pickerStyle(.segmented) .padding(.horizontal) .padding(.vertical, 8) }, editor: { item, all, done in CategoryEditor(item: item, all: all, done: done) } ) } static func typLabel(_ tracking: String?) -> String { tracking == "object" ? "Gegenstand" : "Lebensmittel" } } /// 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?, 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) } 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.") } } 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) } } } .navigationTitle(item == nil ? "Kategorie anlegen" : "Kategorie bearbeiten") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } ToolbarItem(placement: .topBarTrailing) { Button(busy ? "Sichern…" : "Sichern") { Task { await submit() } } .disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty) } } } } private func submit() async { busy = true defer { busy = false } let n = name.trimmingCharacters(in: .whitespaces) do { if let item { _ = 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: 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() } catch { self.error = error.localizedDescription } } } 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. struct ShopsView: View { var body: some View { MasterDataListView( title: "Shops", singular: "Der Shop", load: { try await APIClient.shared.shops().map { // Die Website steht im Untertitel und wird beim Bearbeiten // wieder daraus gelesen (wie bei den Gebinden die Mehrzahl). MasterDataItem(id: $0.id, title: $0.name, subtitle: $0.website ?? "", isBuiltin: false) } }, delete: { try await APIClient.shared.deleteShop(id: $0) } ) { item, done in ShopEditor(id: item?.id, name: item?.title ?? "", website: item?.subtitle ?? "", done: done) } } } /// Shops haben Name und optionale Website. struct ShopEditor: View { @Environment(\.dismiss) private var dismiss let id: Int? let done: () -> Void @State private var name: String @State private var website: String @State private var error: String? @State private var busy = false init(id: Int?, name: String, website: String, done: @escaping () -> Void) { self.id = id self.done = done _name = State(initialValue: name) _website = State(initialValue: website) } var body: some View { NavigationStack { Form { Section("Name") { TextField("z. B. Digitec", text: $name) } Section { TextField("https://…", text: $website) .keyboardType(.URL) .textInputAutocapitalization(.never) .autocorrectionDisabled() } header: { Text("Website (optional)") } if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } } .navigationTitle(id == nil ? "Shop anlegen" : "Shop bearbeiten") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } ToolbarItem(placement: .topBarTrailing) { Button(busy ? "Sichern…" : "Sichern") { Task { await submit() } } .disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty) } } } } private func submit() async { busy = true defer { busy = false } let n = name.trimmingCharacters(in: .whitespaces) // Leeres Feld bewusst als "" senden (nicht nil): der Server setzt die // Website damit auf leer – so lässt sie sich auch wieder entfernen. let w = website.trimmingCharacters(in: .whitespaces) do { if let id { _ = try await APIClient.shared.updateShop(id: id, ShopUpdateRequest(name: n, website: w)) } else { _ = try await APIClient.shared.createShop(NewShopRequest(name: n, website: w)) } done() dismiss() } catch { self.error = error.localizedDescription } } }