Die App konnte fuenf Dinge, die Web-Oberflaeche vierzehn. Alles darueber hinaus
- nachsehen was gestern passiert ist, einen Lagerort anlegen - zwang zurueck an
den Browser.
Mehrere Server: Statt einer Adresse und einem Token kennt die App jetzt
beliebig viele Profile, jedes mit eigenem Token im Keychain. Ein Wechsel ist
damit ein Tipp und meldet an keinem der Server ab. Jedes Profil hat einen frei
waehlbaren Namen ("Zuhause"), der oben in der App steht; ohne Angabe faellt er
auf den Rechnernamen zurueck. Eine bestehende Anmeldung wird beim ersten Start
in ein Profil ueberfuehrt - ohne das waere man nach dem Update abgemeldet.
Statt einer Startseite mit Kacheln gibt es vier Tabs: Start, Scannen, Listen
und - nur fuer Administratoren, wie im Web - Verwaltung. Neu darin sind eine
Uebersicht mit den Kennzahlen des Servers, die per Tipp in die passende Liste
fuehren, der Bewegungsverlauf (auch je Artikel, weil /movements danach filtern
kann) und die Stammdaten: Lagerorte, Einheiten, Gebinde, Kategorien und
Gruppen. Die fuenf teilen sich eine Ansicht und unterscheiden sich nur darin,
wie geladen und geschrieben wird.
Zwei Dinge, die beim Pruefen gegen einen echten Server auffielen: Der Server
schickt Zeitstempel in UTC, haengt hinter SQLite aber keine Zeitzone an. Naiv
gelesen haette die App jede Uhrzeit um die eigene Zeitverschiebung daneben
angezeigt, deshalb wird die nackte Form ausdruecklich als UTC gelesen. Und die
Anzeigeeinstellungen gehoeren dem Server, werden beim Wechsel also neu geladen.
Bewusst nicht dabei: Benutzer, Einstellungen, Branding und Import/Export. Die
bleiben vorerst im Web.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
553 lines
19 KiB
Swift
553 lines
19 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")
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
MasterDataListView(
|
|
title: "Lagerorte",
|
|
singular: "Der Lagerort",
|
|
load: {
|
|
try await APIClient.shared.locations().map {
|
|
MasterDataItem(id: $0.id, title: $0.name, subtitle: "", isBuiltin: false)
|
|
}
|
|
},
|
|
delete: { try await APIClient.shared.deleteLocation(id: $0) }
|
|
) { item, done in
|
|
NameEditor(
|
|
title: item == nil ? "Lagerort anlegen" : "Lagerort umbenennen",
|
|
initial: item?.title ?? "",
|
|
save: { name in
|
|
if let item {
|
|
_ = try await APIClient.shared.renameLocation(id: item.id, name: name)
|
|
} else {
|
|
_ = try await APIClient.shared.createLocation(
|
|
NewLocationRequest(name: name, parentId: nil))
|
|
}
|
|
},
|
|
done: done
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
struct CategoriesView: View {
|
|
var body: some View {
|
|
MasterDataListView(
|
|
title: "Kategorien",
|
|
singular: "Die Kategorie",
|
|
load: {
|
|
let alle = try await APIClient.shared.categories()
|
|
// Baumreihenfolge mit Einrueckung, damit die Verschachtelung
|
|
// sichtbar bleibt (wie in der Produktliste).
|
|
return CategoriesView.tree(alle).map {
|
|
MasterDataItem(id: $0.item.id,
|
|
title: String(repeating: " ", count: $0.depth) + $0.item.name,
|
|
subtitle: "", isBuiltin: false)
|
|
}
|
|
},
|
|
delete: { try await APIClient.shared.deleteCategory(id: $0) }
|
|
) { item, done in
|
|
NameEditor(
|
|
title: item == nil ? "Kategorie anlegen" : "Kategorie umbenennen",
|
|
initial: item?.title.trimmingCharacters(in: .whitespaces) ?? "",
|
|
save: { name in
|
|
if let item {
|
|
_ = try await APIClient.shared.renameCategory(id: item.id, name: name)
|
|
} else {
|
|
_ = try await APIClient.shared.createCategory(
|
|
NewCategoryRequest(name: name, parentId: nil))
|
|
}
|
|
},
|
|
done: done
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct Node {
|
|
let item: CategoryItem
|
|
let depth: Int
|
|
}
|
|
|
|
private static func tree(_ categories: [CategoryItem]) -> [Node] {
|
|
let ids = Set(categories.map(\.id))
|
|
var result: [Node] = []
|
|
|
|
func walk(_ node: CategoryItem, _ depth: Int) {
|
|
result.append(Node(item: node, depth: depth))
|
|
for child in categories.filter({ $0.parentId == node.id }) {
|
|
walk(child, depth + 1)
|
|
}
|
|
}
|
|
// Waisen (Oberkategorie geloescht) gelten als oberste Ebene.
|
|
for root in categories.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
|
|
walk(root, 0)
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
}
|
|
}
|