Files
Vorrania/ios/Sources/MasterDataViews.swift
Scarriffle 956a472c04 iOS: Kategorie-Typfilter + Lagerort-Hierarchie (aufklappbarer Baum)
Gemeinsame TreeMasterView fuer hierarchische Stammdaten (Ein-/Ausklappen,
Anlegen, Bearbeiten, Loeschen):
- Kategorien: Segment-Filter Alle/Lebensmittel/Gegenstaende (wie im Web); in
  der Alle-Ansicht zeigt jede Zeile ihre Art. Anlegen/Bearbeiten waehlt die
  Verwaltungsart (neu: createCategory mit tracking, updateCategory).
- Lagerorte: werden jetzt als aufklappbarer Baum gezeigt statt flach. Beim
  Anlegen laesst sich ein Elternort waehlen (Hierarchie aufbauen); Umhaengen
  unterstuetzt der Server nicht, Bearbeiten bleibt daher Umbenennen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 13:01:28 +02:00

969 lines
34 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Editor: View>: 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<Item: TreeItem, Header: View, Editor: View>: 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<Int> = []
@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 (mit optionalem Elternort, um die Hierarchie aufzubauen)
/// oder umbenennen. Umhängen unterstützt der Server nicht beim Bearbeiten
/// gibt es deshalb nur den Namen.
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)
}
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))
}
}
} header: {
Text("Übergeordneter Lagerort (optional)")
}
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
}
.navigationTitle(item == nil ? "Lagerort anlegen" : "Lagerort umbenennen")
.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.renameLocation(id: item.id, name: n)
} else {
_ = try await APIClient.shared.createLocation(
NewLocationRequest(name: n, parentId: parentId))
}
done()
dismiss()
} catch {
self.error = error.localizedDescription
}
}
/// "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, _, done in
CategoryEditor(item: item, done: done)
}
)
}
static func typLabel(_ tracking: String?) -> String {
tracking == "object" ? "Gegenstand" : "Lebensmittel"
}
}
/// Kategorie anlegen oder bearbeiten: Name und Verwaltungsart (Lebensmittel vs.
/// Gegenstand). Neue Kategorien entstehen auf oberster Ebene; Verschachteln
/// bleibt der Web-Oberfläche vorbehalten.
struct CategoryEditor: View {
@Environment(\.dismiss) private var dismiss
let item: CategoryItem?
let done: () -> Void
@State private var name: String
@State private var tracking: String
@State private var error: String?
@State private var busy = false
init(item: CategoryItem?, done: @escaping () -> Void) {
self.item = item
self.done = done
_name = State(initialValue: item?.name ?? "")
_tracking = State(initialValue: item?.tracking ?? "object")
}
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")
}
.pickerStyle(.segmented)
} header: {
Text("Verwaltungsart")
} footer: {
Text("Lebensmittel: Chargen mit Mindesthaltbarkeit. Gegenstand: Menge je Lagerort bzw. Einzelstücke. Unterkategorien erben die Art.")
}
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 {
_ = try await APIClient.shared.createCategory(
NewCategoryRequest(name: n, parentId: nil, tracking: tracking))
}
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
}
}
}