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>
1226 lines
44 KiB
Swift
1226 lines
44 KiB
Swift
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: (Item.ID) 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<Item.ID> = []
|
||
@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: Item.ID { 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: Item.ID) -> Int { shown.filter { $0.parentId == id }.count }
|
||
|
||
private var visible: [Node] {
|
||
let parent = Dictionary(uniqueKeysWithValues: shown.map { ($0.id, $0.parentId) })
|
||
func hidden(_ id: Item.ID) -> 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: Item.ID) {
|
||
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: String?
|
||
@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(String?.none)
|
||
ForEach(parentOptions) { loc in
|
||
Text(LocationEditor.pfad(loc, in: all)).tag(String?.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: String, in all: [StorageLocation]) -> Set<String> {
|
||
var result: Set<String> = []
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
/// 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
|
||
}
|
||
}
|
||
}
|