diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index 386613f..c0bc7b2 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -199,6 +199,26 @@ actor APIClient { try await send(try makeRequest("/dashboard/layouts"), as: DashboardList.self) } + // MARK: - Diagrammdaten + + func expirySplit() async throws -> ExpirySplit { + try await send(try makeRequest("/dashboard/expiry-split"), as: ExpirySplit.self) + } + + func byCategory() async throws -> [CategoryShare] { + try await send(try makeRequest("/dashboard/by-category"), as: [CategoryShare].self) + } + + func timeline(days: Int, productId: Int? = nil) async throws -> [TimelinePoint] { + var path = "/dashboard/timeline?days=\(days)" + if let productId { path += "&product_id=\(productId)" } + return try await send(try makeRequest(path), as: [TimelinePoint].self) + } + + func activity(days: Int) async throws -> [ActivityPoint] { + try await send(try makeRequest("/dashboard/activity?days=\(days)"), as: [ActivityPoint].self) + } + /// Neueste zuerst. `productId` filtert auf einen Artikel. func movements(limit: Int = 100, productId: Int? = nil) async throws -> [Movement] { var path = "/movements?limit=\(limit)" diff --git a/ios/Sources/AppDashboards.swift b/ios/Sources/AppDashboards.swift new file mode 100644 index 0000000..f988ece --- /dev/null +++ b/ios/Sources/AppDashboards.swift @@ -0,0 +1,120 @@ +import Foundation + +/// Eine **eigene** Übersicht der App - unabhängig von den Web-Dashboards und nur +/// auf diesem Gerät gespeichert. Je Server wählbar, ob die Web-Dashboards +/// gespiegelt werden oder diese lokale Übersicht gilt. + +/// Modus je Server. +enum OverviewMode: String, Codable { + case mirror // die Web-Dashboards spiegeln (Standard) + case local // eigene App-Übersicht +} + +/// Eine Karte der lokalen Übersicht. Einspaltig - die Reihenfolge im Array +/// bestimmt die Anordnung, deshalb kein Raster. +struct AppCard: Codable, Identifiable, Equatable { + let id: UUID + var type: String + var productId: Int? + + init(id: UUID = UUID(), type: String, productId: Int? = nil) { + self.id = id + self.type = type + self.productId = productId + } +} + +struct AppDashboard: Codable, Identifiable, Equatable { + let id: UUID + var name: String + var cards: [AppCard] + + init(id: UUID = UUID(), name: String, cards: [AppCard] = []) { + self.id = id + self.name = name + self.cards = cards + } + + /// Sinnvolle Startkarten, wenn jemand die eigene Übersicht zum ersten Mal + /// anlegt - eine brauchbare Zusammenstellung statt einer leeren Seite. + static func standard() -> AppDashboard { + AppDashboard(name: "Übersicht", cards: [ + AppCard(type: "status"), + AppCard(type: "checkin-quick"), + AppCard(type: "shopping"), + AppCard(type: "expiry-all"), + AppCard(type: "movements"), + ]) + } +} + +/// Was ein Server lokal an Übersicht hat: der Modus und die eigenen Dashboards. +struct AppOverviewConfig: Codable, Equatable { + var mode: OverviewMode = .mirror + var dashboards: [AppDashboard] = [] +} + +/// Ablage je Profil-UUID in den UserDefaults, wie `NotificationStore`. +enum AppDashboardStore { + static let key = "app_overviews" + + static func loadAll() -> [String: AppOverviewConfig] { + guard let data = UserDefaults.standard.data(forKey: key), + let dict = try? JSONDecoder().decode([String: AppOverviewConfig].self, from: data) + else { return [:] } + return dict + } + + static func config(for profileID: UUID) -> AppOverviewConfig { + loadAll()[profileID.uuidString] ?? AppOverviewConfig() + } + + static func save(_ config: AppOverviewConfig, for profileID: UUID) { + var alle = loadAll() + alle[profileID.uuidString] = config + guard let data = try? JSONEncoder().encode(alle) else { return } + UserDefaults.standard.set(data, forKey: key) + } + + static func remove(for profileID: UUID) { + var alle = loadAll() + alle.removeValue(forKey: profileID.uuidString) + guard let data = try? JSONEncoder().encode(alle) else { return } + UserDefaults.standard.set(data, forKey: key) + } +} + +/// Verfügbare Kartenarten für die lokale Übersicht - Grundlage des Hinzufügen-Menüs. +struct CardKind: Identifiable { + let type: String + let needsProduct: Bool + var id: String { type } + var title: String { CardCatalog.titel(type) } + var isChart: Bool { CardCatalog.istDiagramm(type) } +} + +enum CardCatalogList { + /// Reihenfolge wie sie im Hinzufügen-Menü erscheinen soll. + static let all: [CardKind] = [ + CardKind(type: "status", needsProduct: false), + CardKind(type: "kpi-products", needsProduct: false), + CardKind(type: "kpi-units", needsProduct: false), + CardKind(type: "checkin-quick", needsProduct: false), + CardKind(type: "checkout-quick", needsProduct: false), + CardKind(type: "actions", needsProduct: false), + CardKind(type: "shopping", needsProduct: false), + CardKind(type: "expiry-all", needsProduct: false), + CardKind(type: "expiring", needsProduct: false), + CardKind(type: "expired", needsProduct: false), + CardKind(type: "movements", needsProduct: false), + CardKind(type: "product-stock", needsProduct: true), + CardKind(type: "product-timeline", needsProduct: true), + CardKind(type: "expiry-donut", needsProduct: false), + CardKind(type: "category-donut", needsProduct: false), + CardKind(type: "category-bars", needsProduct: false), + CardKind(type: "stock-timeline", needsProduct: false), + CardKind(type: "activity-timeline", needsProduct: false), + ] + + static func kind(for type: String) -> CardKind? { all.first { $0.type == type } } +} diff --git a/ios/Sources/ChartCards.swift b/ios/Sources/ChartCards.swift new file mode 100644 index 0000000..d2156cd --- /dev/null +++ b/ios/Sources/ChartCards.swift @@ -0,0 +1,283 @@ +import SwiftUI +import Charts + +/// Native Diagrammkarten der Übersicht (Swift Charts). Ersetzt den früheren +/// „gibt es nur im Web"-Hinweis. +/// +/// Ringe (SectorMark) gibt es erst ab iOS 17; auf iOS 16 tritt an ihre Stelle +/// ein 100-%-Balken, der dieselben Anteile zeigt. Balken und Linien laufen auf +/// beiden. + +/// Farben der Ablaufzustände - überall gleich, damit Grün/Orange/Rot dasselbe +/// bedeuten. +enum ExpiryColor { + static let ok = Color.green + static let soon = Color.orange + static let expired = Color.red + static let noDate = Color.gray +} + +/// Ein Anteil für Ring bzw. 100-%-Balken. +private struct Segment: Identifiable { + let id = UUID() + let label: String + let value: Double + let color: Color +} + +/// Ring (iOS 17+) oder 100-%-Balken (iOS 16) plus Legende. +private struct ProportionChart: View { + let segments: [Segment] + let einheit: String + + private var sichtbar: [Segment] { segments.filter { $0.value > 0 } } + + var body: some View { + if sichtbar.isEmpty { + Text("Keine Daten.").font(.caption).foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 8) { + if #available(iOS 17.0, *) { + Chart(sichtbar) { seg in + SectorMark(angle: .value("Anteil", seg.value), + innerRadius: .ratio(0.62), angularInset: 1.5) + .foregroundStyle(seg.color) + } + .frame(height: 160) + } else { + Chart(sichtbar) { seg in + BarMark(x: .value("Anteil", seg.value), y: .value("", "Bestand")) + .foregroundStyle(seg.color) + } + .chartXAxis(.hidden).chartYAxis(.hidden) + .frame(height: 28) + } + legende + } + } + } + + private var legende: some View { + VStack(alignment: .leading, spacing: 3) { + ForEach(sichtbar) { seg in + HStack(spacing: 6) { + Circle().fill(seg.color).frame(width: 8, height: 8) + Text(seg.label).font(.caption) + Spacer() + Text("\(formatAmount(seg.value)) \(einheit)") + .font(.caption).foregroundStyle(.secondary) + } + } + } + } +} + +/// Waehlt anhand der Art die passende Diagrammkarte (ohne Artikelbezug). +struct ChartCardView: View { + let type: String + let stand: Int + + var body: some View { + switch type { + case "expiry-donut": ExpiryDonutCard(stand: stand) + case "category-donut": CategoryDonutCard(stand: stand) + case "category-bars": CategoryBarsCard(stand: stand) + case "stock-timeline": StockTimelineCard(stand: stand) + case "activity-timeline": ActivityTimelineCard(stand: stand) + default: + Text("Unbekanntes Diagramm „\(type)“.").font(.caption).foregroundStyle(.secondary) + } + } +} + +// MARK: - Ladehülle + +/// Nimmt die wiederkehrende Lade-/Fehlerlogik der Diagrammkarten ab. +private struct ChartLoader: View { + let laden: () async throws -> Data + let stand: Int + @ViewBuilder let inhalt: (Data) -> Inhalt + + @State private var daten: Data? + @State private var fehler: String? + + var body: some View { + Group { + if let fehler { + Text(fehler).font(.caption).foregroundStyle(.red) + } else if let daten { + inhalt(daten) + } else { + Text("Lädt…").font(.caption).foregroundStyle(.secondary) + } + } + .task(id: stand) { + do { daten = try await laden(); fehler = nil } + catch { self.fehler = error.localizedDescription } + } + } +} + +// MARK: - Ablauf-Anteile + +struct ExpiryDonutCard: View { + let stand: Int + var body: some View { + ChartLoader(laden: { try await APIClient.shared.expirySplit() }, stand: stand) { s in + ProportionChart(segments: [ + Segment(label: "In Ordnung", value: s.ok, color: ExpiryColor.ok), + Segment(label: "Bald ablaufend", value: s.soon, color: ExpiryColor.soon), + Segment(label: "Abgelaufen", value: s.expired, color: ExpiryColor.expired), + Segment(label: "Ohne MHD", value: s.noDate, color: ExpiryColor.noDate), + ], einheit: "AE") + } + } +} + +// MARK: - Kategorien-Anteile + +struct CategoryDonutCard: View { + let stand: Int + + // Wiederholbare, ruhige Farbfolge fuer die Kategorien. + private static let palette: [Color] = [ + .blue, .green, .orange, .purple, .pink, .teal, .indigo, .brown, + ] + + var body: some View { + ChartLoader(laden: { try await APIClient.shared.byCategory() }, stand: stand) { liste in + let sortiert = liste.filter { $0.articleUnits > 0 } + .sorted { $0.articleUnits > $1.articleUnits } + // Die groessten sechs einzeln, der Rest gebuendelt - sonst wird der + // Ring unlesbar. + let kopf = sortiert.prefix(6) + let restWert = sortiert.dropFirst(6).reduce(0) { $0 + $1.articleUnits } + var segmente = kopf.enumerated().map { i, c in + Segment(label: c.name, value: c.articleUnits, + color: Self.palette[i % Self.palette.count]) + } + if restWert > 0 { + segmente.append(Segment(label: "Übrige", value: restWert, color: .gray)) + } + return ProportionChart(segments: segmente, einheit: "AE") + } + } +} + +// MARK: - Kategorien nach Zustand (gestapelte Balken) + +struct CategoryBarsCard: View { + let stand: Int + + private struct Zeile: Identifiable { + let id = UUID() + let kategorie: String + let zustand: String + let wert: Double + let farbe: Color + } + + var body: some View { + ChartLoader(laden: { try await APIClient.shared.byCategory() }, stand: stand) { liste in + let top = liste.filter { $0.articleUnits > 0 } + .sorted { $0.articleUnits > $1.articleUnits }.prefix(8) + let zeilen: [Zeile] = top.flatMap { c in + [ + Zeile(kategorie: c.name, zustand: "In Ordnung", wert: c.ok, farbe: ExpiryColor.ok), + Zeile(kategorie: c.name, zustand: "Bald", wert: c.soon, farbe: ExpiryColor.soon), + Zeile(kategorie: c.name, zustand: "Abgelaufen", wert: c.expired, farbe: ExpiryColor.expired), + Zeile(kategorie: c.name, zustand: "Ohne MHD", wert: c.noDate, farbe: ExpiryColor.noDate), + ] + } + return Chart(zeilen) { z in + BarMark(x: .value("Artikeleinheiten", z.wert), + y: .value("Kategorie", z.kategorie)) + .foregroundStyle(z.farbe) + } + .chartLegend(.hidden) + .frame(height: max(120, CGFloat(top.count) * 34)) + } + } +} + +// MARK: - Bestandsverlauf (Linie) + +struct StockTimelineCard: View { + let stand: Int + var body: some View { + ChartLoader(laden: { try await APIClient.shared.timeline(days: 90) }, stand: stand) { punkte in + LinienDiagramm(punkte: punkte) + } + } +} + +/// Verlauf **eines** Artikels - dieselbe Linie, nur gefiltert. +struct ProductTimelineChart: View { + let productId: Int? + let stand: Int + var body: some View { + if let productId { + ChartLoader(laden: { try await APIClient.shared.timeline(days: 90, productId: productId) }, + stand: stand) { punkte in + LinienDiagramm(punkte: punkte) + } + } else { + Text("Kein Artikel gewählt – beim Bearbeiten einstellen.") + .font(.caption).foregroundStyle(.secondary) + } + } +} + +private struct LinienDiagramm: View { + let punkte: [TimelinePoint] + + var body: some View { + if punkte.isEmpty { + Text("Keine Daten.").font(.caption).foregroundStyle(.secondary) + } else { + Chart(punkte) { p in + if let datum = DisplaySettings.parseTimestamp(p.at) { + LineMark(x: .value("Zeit", datum), y: .value("Bestand", p.articleUnits)) + .foregroundStyle(Color.accentColor) + AreaMark(x: .value("Zeit", datum), y: .value("Bestand", p.articleUnits)) + .foregroundStyle(Color.accentColor.opacity(0.12)) + } + } + .frame(height: 170) + } + } +} + +// MARK: - Ein- und Auslagerungen (gruppierte Balken) + +struct ActivityTimelineCard: View { + let stand: Int + + private struct Balken: Identifiable { + let id = UUID() + let datum: Date + let richtung: String + let wert: Int + } + + var body: some View { + ChartLoader(laden: { try await APIClient.shared.activity(days: 30) }, stand: stand) { punkte in + let balken: [Balken] = punkte.compactMap { p in + DisplaySettings.parseTimestamp(p.at).map { d in [ + Balken(datum: d, richtung: "Eingelagert", wert: p.checkedIn), + Balken(datum: d, richtung: "Ausgelagert", wert: p.checkedOut), + ] } ?? [] + }.flatMap { $0 } + + return Chart(balken) { b in + BarMark(x: .value("Tag", b.datum, unit: .day), y: .value("Anzahl", b.wert)) + .foregroundStyle(by: .value("Richtung", b.richtung)) + .position(by: .value("Richtung", b.richtung)) + } + .chartForegroundStyleScale([ + "Eingelagert": Color.green, "Ausgelagert": Color.orange, + ]) + .frame(height: 170) + } + } +} diff --git a/ios/Sources/DashboardView.swift b/ios/Sources/DashboardView.swift index 11a7c4c..278160f 100644 --- a/ios/Sources/DashboardView.swift +++ b/ios/Sources/DashboardView.swift @@ -1,83 +1,120 @@ import SwiftUI -/// Zeigt die Dashboards, die auch die Web-Oberflaeche kennt. -/// -/// Das Raster (x/y/w/h) laesst sich auf einem Telefon nicht sinnvoll -/// nachbilden - hier stehen die Karten deshalb untereinander, in der -/// Lesereihenfolge des Rasters (erst Zeile, dann Spalte). Zusammengestellt -/// werden Dashboards weiterhin am Rechner; die App zeigt sie. +/// Der Start-Tab. Je Server wird entweder die Web-Übersicht gespiegelt oder eine +/// eigene, lokale App-Übersicht gezeigt - umschaltbar über das Menü oben. struct DashboardView: View { @EnvironmentObject private var session: Session + @State private var mode: OverviewMode = .mirror + @State private var manageShown = false + + private var profileID: UUID? { session.activeProfileID } + + var body: some View { + NavigationStack { + Group { + if mode == .local, let profileID { + LocalOverview(profileID: profileID) + } else { + MirrorOverview() + } + } + .navigationTitle(session.activeProfile?.name ?? "Übersicht") + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Menu { + Picker("Übersicht", selection: Binding( + get: { mode }, + set: { neu in setzeModus(neu) } + )) { + Label("Web-Übersicht spiegeln", systemImage: "arrow.triangle.2.circlepath") + .tag(OverviewMode.mirror) + Label("Eigene App-Übersicht", systemImage: "square.grid.2x2") + .tag(OverviewMode.local) + } + } label: { + Image(systemName: "rectangle.3.group") + } + } + ToolbarItem(placement: .topBarTrailing) { + ServerMenu(manageShown: $manageShown) + } + } + .sheet(isPresented: $manageShown) { ServerListView() } + } + .task(id: profileID) { ladeModus() } + } + + private func ladeModus() { + guard let profileID else { mode = .mirror; return } + mode = AppDashboardStore.config(for: profileID).mode + } + + private func setzeModus(_ neu: OverviewMode) { + guard let profileID else { return } + var config = AppDashboardStore.config(for: profileID) + // Beim ersten Umschalten auf „eigen" gleich eine sinnvolle Übersicht anlegen. + if neu == .local && config.dashboards.isEmpty { + config.dashboards = [AppDashboard.standard()] + } + config.mode = neu + AppDashboardStore.save(config, for: profileID) + mode = neu + } +} + +// MARK: - Web-Übersicht spiegeln + +/// Zeigt die Dashboards der Web-Oberfläche (schreibgeschützt). Das Raster +/// (x/y/w/h) lässt sich auf dem Telefon nicht nachbilden - die Karten stehen +/// deshalb untereinander, in der Lesereihenfolge des Rasters. +struct MirrorOverview: View { + @EnvironmentObject private var session: Session + @State private var dashboards: [Dashboard] = [] @State private var auswahl: Int? @State private var busy = true @State private var fehler: String? - @State private var manageShown = false - /// Zaehler, der die Karten nach einer Buchung neu laden laesst. @State private var stand = 0 private var aktives: Dashboard? { dashboards.first { $0.id == auswahl } ?? dashboards.first } - /// Rasterreihenfolge: erst von oben nach unten, dann von links nach rechts. private var karten: [DashboardCard] { - (aktives?.layout ?? []).sorted { - $0.y == $1.y ? $0.x < $1.x : $0.y < $1.y - } + (aktives?.layout ?? []).sorted { $0.y == $1.y ? $0.x < $1.x : $0.y < $1.y } } var body: some View { - NavigationStack { - List { - if let fehler { - Section { Text(fehler).foregroundStyle(.red).font(.callout) } - } - - // Mehrere Dashboards: oben umschalten. Bei einem einzigen waere - // die Auswahl nur Beiwerk. - if dashboards.count > 1 { - Section { - Picker("Dashboard", selection: Binding( - get: { aktives?.id ?? 0 }, - set: { auswahl = $0 } - )) { - ForEach(dashboards) { d in - Text(d.name).tag(d.id) - } - } - .pickerStyle(.menu) + List { + if let fehler { + Section { Text(fehler).foregroundStyle(.red).font(.callout) } + } + if dashboards.count > 1 { + Section { + Picker("Dashboard", selection: Binding( + get: { aktives?.id ?? 0 }, set: { auswahl = $0 } + )) { + ForEach(dashboards) { Text($0.name).tag($0.id) } } - } - - ForEach(karten) { karte in - Section { - DashboardCardView(karte: karte, stand: stand) { stand += 1 } - } header: { - Text(titel(fuer: karte)) - } - } - - if karten.isEmpty && !busy { - Text("Dieses Dashboard hat keine Karten. Zusammenstellen lässt es sich in der Web-Oberfläche.") - .foregroundStyle(.secondary) + .pickerStyle(.menu) } } - .navigationTitle(aktives?.name ?? session.activeProfile?.name ?? "Übersicht") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - ServerMenu(manageShown: $manageShown) + ForEach(karten) { karte in + Section { + DashboardCardView(type: karte.type, productId: karte.props?.productId, + stand: stand) { stand += 1 } + } header: { + Text(CardCatalog.titel(karte.type)) } } - .sheet(isPresented: $manageShown) { ServerListView() } - .refreshable { await laden() } - .task(id: session.activeProfileID) { await laden() } + if karten.isEmpty && !busy { + Text("Dieses Dashboard hat keine Karten. Zusammenstellen lässt es sich in der Web-Oberfläche.") + .foregroundStyle(.secondary) + } } - } - - private func titel(fuer karte: DashboardCard) -> String { - CardCatalog.titel(karte.type) + .refreshable { await laden() } + .task(id: session.activeProfileID) { await laden() } } private func laden() async { @@ -130,14 +167,17 @@ enum CardCatalog { } } -/// Waehlt anhand der Kartenart den passenden Inhalt. +/// Waehlt anhand der Kartenart den passenden Inhalt. Wird sowohl beim Spiegeln +/// der Web-Dashboards als auch in der eigenen App-Uebersicht verwendet - daher +/// nur ueber Art und (fuer Artikelkarten) Produkt-Id parametrisiert. struct DashboardCardView: View { - let karte: DashboardCard + let type: String + let productId: Int? let stand: Int let onChange: () -> Void var body: some View { - switch karte.type { + switch type { case "checkin-quick": QuickBookingCard(richtung: .ein, onChange: onChange) case "checkout-quick": @@ -145,23 +185,23 @@ struct DashboardCardView: View { case "actions": ActionsCard() case "status", "kpi-products", "kpi-units": - StatusCard(art: karte.type, stand: stand) + StatusCard(art: type, stand: stand) case "shopping": ShoppingCard(stand: stand) case "expiring", "expired", "expiry-all": - ExpiryCard(modus: karte.type, stand: stand) + ExpiryCard(modus: type, stand: stand) case "movements": MovementsCard(stand: stand) - case "product-stock", "product-timeline": - ProductCard(productId: karte.props?.productId, stand: stand) + case "product-stock": + ProductCard(productId: productId, stand: stand) + case "product-timeline": + ProductTimelineChart(productId: productId, stand: stand) + case "expiry-donut", "category-donut", "category-bars", + "stock-timeline", "activity-timeline": + ChartCardView(type: type, stand: stand) default: - if CardCatalog.istDiagramm(karte.type) { - Label("Diagramme gibt es in der Web-Oberfläche.", systemImage: "chart.xyaxis.line") - .font(.caption).foregroundStyle(.secondary) - } else { - Text("Unbekannte Karte „\(karte.type)“.") - .font(.caption).foregroundStyle(.secondary) - } + Text("Unbekannte Karte „\(type)“.") + .font(.caption).foregroundStyle(.secondary) } } } diff --git a/ios/Sources/LocalOverview.swift b/ios/Sources/LocalOverview.swift new file mode 100644 index 0000000..5d2a5bc --- /dev/null +++ b/ios/Sources/LocalOverview.swift @@ -0,0 +1,248 @@ +import SwiftUI + +/// Die eigene, lokale App-Übersicht eines Servers: mehrere Dashboards, Karten +/// untereinander, frei zusammenstellbar. Nur auf diesem Gerät gespeichert. +/// +/// Anzeige und Bearbeitung sind getrennt: Beim Bearbeiten zeigt eine kompakte, +/// sortierbare Liste die Karten (so funktionieren Ziehen und Löschen sauber), +/// beim Ansehen die vollen Karten. +struct LocalOverview: View { + let profileID: UUID + + @State private var config = AppOverviewConfig() + @State private var auswahl: UUID? + @State private var bearbeiten = false + @State private var addShown = false + @State private var produkte: [Product] = [] + @State private var stand = 0 + @State private var renameText = "" + @State private var renameShown = false + + private var aktives: AppDashboard? { + config.dashboards.first { $0.id == auswahl } ?? config.dashboards.first + } + + var body: some View { + Group { + if bearbeiten { + editierListe + } else { + anzeige + } + } + .toolbar { ToolbarItemGroup(placement: .bottomBar) { werkzeugleiste } } + .sheet(isPresented: $addShown) { + CardPickerView { neu in fuegeHinzu(neu) } + } + .alert("Dashboard umbenennen", isPresented: $renameShown) { + TextField("Name", text: $renameText) + Button("Sichern") { speichereName() } + Button("Abbrechen", role: .cancel) {} + } + .task(id: profileID) { + config = AppDashboardStore.config(for: profileID) + if auswahl == nil || !config.dashboards.contains(where: { $0.id == auswahl }) { + auswahl = config.dashboards.first?.id + } + produkte = (try? await APIClient.shared.searchProducts("")) ?? [] + } + } + + // MARK: - Anzeige + + private var anzeige: some View { + List { + if config.dashboards.count > 1 { + Section { dashboardPicker } + } + if let aktives { + ForEach(aktives.cards) { karte in + Section { + DashboardCardView(type: karte.type, productId: karte.productId, + stand: stand) { stand += 1 } + } header: { + Text(titel(karte)) + } + } + if aktives.cards.isEmpty { + Text("Noch keine Karten. Über „Bearbeiten“ lassen sich welche hinzufügen.") + .foregroundStyle(.secondary) + } + } + } + .refreshable { stand += 1 } + } + + // MARK: - Bearbeiten + + private var editierListe: some View { + List { + if config.dashboards.count > 1 { + Section { dashboardPicker } + } + Section { + ForEach(aktives?.cards ?? []) { karte in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(CardCatalog.titel(karte.type)) + if CardCatalogList.kind(for: karte.type)?.needsProduct == true { + artikelWahl(fuer: karte) + } + } + Spacer() + } + } + .onDelete(perform: loesche) + .onMove(perform: verschiebe) + } header: { + Text("Karten – ziehen zum Sortieren") + } + } + .environment(\.editMode, .constant(.active)) + } + + private var dashboardPicker: some View { + Picker("Dashboard", selection: Binding( + get: { aktives?.id ?? UUID() }, set: { auswahl = $0 } + )) { + ForEach(config.dashboards) { Text($0.name).tag($0.id) } + } + .pickerStyle(.menu) + } + + @ViewBuilder private var werkzeugleiste: some View { + if bearbeiten { + Button { addShown = true } label: { Label("Karte", systemImage: "plus") } + Spacer() + Menu { + Button("Dashboard hinzufügen", systemImage: "plus") { neuesDashboard() } + if aktives != nil { + Button("Umbenennen", systemImage: "pencil") { + renameText = aktives?.name ?? ""; renameShown = true + } + if config.dashboards.count > 1 { + Button("Dashboard löschen", systemImage: "trash", role: .destructive) { + loescheDashboard() + } + } + } + } label: { + Label("Dashboards", systemImage: "rectangle.stack") + } + Spacer() + Button("Fertig") { bearbeiten = false } + } else { + Spacer() + Button { bearbeiten = true } label: { + Label("Bearbeiten", systemImage: "slider.horizontal.3") + } + } + } + + private func titel(_ karte: AppCard) -> String { + if let pid = karte.productId, let p = produkte.first(where: { $0.id == pid }) { + return "\(CardCatalog.titel(karte.type)): \(p.name)" + } + return CardCatalog.titel(karte.type) + } + + private func artikelWahl(fuer karte: AppCard) -> some View { + Menu { + Button("– kein Artikel –") { setzeProdukt(karte, nil) } + ForEach(produkte) { p in + Button(p.name) { setzeProdukt(karte, p.id) } + } + } label: { + Text(produkte.first { $0.id == karte.productId }?.name ?? "Artikel wählen") + .font(.caption).foregroundStyle(Color.accentColor) + } + } + + // MARK: - Datenänderungen (alle sofort gespeichert) + + private func mutiere(_ block: (inout AppDashboard) -> Void) { + guard let id = aktives?.id, + let index = config.dashboards.firstIndex(where: { $0.id == id }) else { return } + block(&config.dashboards[index]) + AppDashboardStore.save(config, for: profileID) + } + + private func fuegeHinzu(_ karte: AppCard) { mutiere { $0.cards.append(karte) } } + + private func setzeProdukt(_ karte: AppCard, _ pid: Int?) { + mutiere { dash in + if let i = dash.cards.firstIndex(where: { $0.id == karte.id }) { + dash.cards[i].productId = pid + } + } + stand += 1 + } + + private func loesche(at offsets: IndexSet) { mutiere { $0.cards.remove(atOffsets: offsets) } } + private func verschiebe(from: IndexSet, to: Int) { mutiere { $0.cards.move(fromOffsets: from, toOffset: to) } } + + private func neuesDashboard() { + let neu = AppDashboard(name: "Neues Dashboard") + config.dashboards.append(neu) + AppDashboardStore.save(config, for: profileID) + auswahl = neu.id + } + + private func loescheDashboard() { + guard let id = aktives?.id else { return } + config.dashboards.removeAll { $0.id == id } + AppDashboardStore.save(config, for: profileID) + auswahl = config.dashboards.first?.id + } + + private func speichereName() { + let name = renameText.trimmingCharacters(in: .whitespaces) + guard !name.isEmpty else { return } + mutiere { $0.name = name } + } +} + +/// Auswahl einer neuen Karte. Diagramme sind eigens gekennzeichnet. +struct CardPickerView: View { + let onPick: (AppCard) -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section("Karten") { + ForEach(CardCatalogList.all.filter { !$0.isChart }) { kind in + zeile(kind) + } + } + Section("Diagramme") { + ForEach(CardCatalogList.all.filter { $0.isChart }) { kind in + zeile(kind) + } + } + } + .navigationTitle("Karte hinzufügen") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } + } + } + } + + private func zeile(_ kind: CardKind) -> some View { + Button { + onPick(AppCard(type: kind.type)) + dismiss() + } label: { + HStack { + Text(kind.title) + if kind.needsProduct { + Text("· Artikel").font(.caption).foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "plus.circle").foregroundStyle(Color.accentColor) + } + } + .foregroundStyle(.primary) + } +} diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index f0390c8..870f699 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -516,6 +516,69 @@ struct DashboardList: Codable { } } +// MARK: - Diagrammdaten (Übersicht) + +/// Artikeleinheiten je Ablaufzustand (GET /dashboard/expiry-split). +struct ExpirySplit: Codable { + let ok: Double + let soon: Double + let expired: Double + let noDate: Double + + enum CodingKeys: String, CodingKey { + case ok, soon, expired + case noDate = "no_date" + } +} + +/// Anteil einer Kategorie am Bestand (GET /dashboard/by-category). +struct CategoryShare: Codable, Identifiable { + let categoryId: Int? + let name: String + let articleUnits: Double + let ok: Double + let soon: Double + let expired: Double + let noDate: Double + + var id: Int { categoryId ?? -1 } + + enum CodingKeys: String, CodingKey { + case name, ok, soon, expired + case categoryId = "category_id" + case articleUnits = "article_units" + case noDate = "no_date" + } +} + +/// Ein Punkt des Bestandsverlaufs (GET /dashboard/timeline). +struct TimelinePoint: Codable, Identifiable { + let at: String + let articleUnits: Double + + var id: String { at } + + enum CodingKeys: String, CodingKey { + case at + case articleUnits = "article_units" + } +} + +/// Ein- und Auslagerungen je Zeitpunkt (GET /dashboard/activity). +struct ActivityPoint: Codable, Identifiable { + let at: String + let checkedIn: Int + let checkedOut: Int + + var id: String { at } + + enum CodingKeys: String, CodingKey { + case at + case checkedIn = "checked_in" + case checkedOut = "checked_out" + } +} + // MARK: - Stammdaten /// Gebinde (Packung, Glas, ...) mit Einzahl und Mehrzahl. diff --git a/ios/Sources/Session.swift b/ios/Sources/Session.swift index 84ff78a..b5a6e0b 100644 --- a/ios/Sources/Session.swift +++ b/ios/Sources/Session.swift @@ -123,6 +123,7 @@ final class Session: ObservableObject { func removeProfile(id: UUID) { Keychain.delete(account: Session.keychainAccount(for: id)) NotificationStore.remove(for: id) + AppDashboardStore.remove(for: id) profiles.removeAll { $0.id == id } ProfileStore.save(profiles) diff --git a/ios/Vorrania.xcodeproj/project.pbxproj b/ios/Vorrania.xcodeproj/project.pbxproj index 712a826..0aa1b26 100644 --- a/ios/Vorrania.xcodeproj/project.pbxproj +++ b/ios/Vorrania.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6785CD9174067EFBB2B9043 /* ListViews.swift */; }; 7E2FA69E0C3BED650E91A7E9 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 099CCD94907BE0179B7D5742 /* AppIcon.icon */; }; 80B15C315FB4FEBD385A5EA1 /* ServerProfiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 121D44B9990DA931A9CD5847 /* ServerProfiles.swift */; }; + 82A0C22D4AD228C36868853A /* LocalOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = C35D34107C8CBB8B7C6C6650 /* LocalOverview.swift */; }; 83F9AD06177F3BC95421F128 /* DashboardCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 090485CC54558EB4433376A9 /* DashboardCards.swift */; }; 87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA706060734632DE78FA1073 /* ServerListView.swift */; }; 926451E72FD13708C5DD657B /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */; }; @@ -34,7 +35,9 @@ DFA55EF4ACA34537F445E998 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2F9406BD0D4F0D30FED345 /* Session.swift */; }; E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A75926511854BB8EE316ED3A /* LoginView.swift */; }; ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717C8EB336170526F5F3E695 /* DateScanView.swift */; }; + F3F718BFC60BF945CFA32BD2 /* AppDashboards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 660ACEFA22BC77D52FBE736A /* AppDashboards.swift */; }; F6C7E913CAB0FAF1826E6BD3 /* CheckOutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC0DDD06332E567E70CA842F /* CheckOutView.swift */; }; + FFD4DB783F89083C9098F1C4 /* ChartCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = E580BA2815BB8F6341D64B47 /* ChartCards.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -51,6 +54,7 @@ 378F1B2DE567B62169E84426 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; 4741D0E95875919C921945CF /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 4BF4E4F5524B15728CEEE116 /* Vorrania.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Vorrania.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 660ACEFA22BC77D52FBE736A /* AppDashboards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDashboards.swift; sourceTree = ""; }; 6F574168AA0F849D46C384EE /* ProductDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailView.swift; sourceTree = ""; }; 717C8EB336170526F5F3E695 /* DateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateScanView.swift; sourceTree = ""; }; 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; @@ -63,8 +67,10 @@ AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BestBeforeText.swift; sourceTree = ""; }; AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplaySettings.swift; sourceTree = ""; }; AD2F9406BD0D4F0D30FED345 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = ""; }; + C35D34107C8CBB8B7C6C6650 /* LocalOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalOverview.swift; sourceTree = ""; }; CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPicker.swift; sourceTree = ""; }; DC0DDD06332E567E70CA842F /* CheckOutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckOutView.swift; sourceTree = ""; }; + E580BA2815BB8F6341D64B47 /* ChartCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartCards.swift; sourceTree = ""; }; E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; EA706060734632DE78FA1073 /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -74,8 +80,10 @@ isa = PBXGroup; children = ( 378F1B2DE567B62169E84426 /* APIClient.swift */, + 660ACEFA22BC77D52FBE736A /* AppDashboards.swift */, AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */, CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */, + E580BA2815BB8F6341D64B47 /* ChartCards.swift */, 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */, 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */, DC0DDD06332E567E70CA842F /* CheckOutView.swift */, @@ -85,6 +93,7 @@ AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */, 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */, A6785CD9174067EFBB2B9043 /* ListViews.swift */, + C35D34107C8CBB8B7C6C6650 /* LocalOverview.swift */, A75926511854BB8EE316ED3A /* LoginView.swift */, 1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */, 314B1BB7220691A7423E8929 /* Models.swift */, @@ -193,8 +202,10 @@ buildActionMask = 2147483647; files = ( 39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */, + F3F718BFC60BF945CFA32BD2 /* AppDashboards.swift in Sources */, A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */, 25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */, + FFD4DB783F89083C9098F1C4 /* ChartCards.swift in Sources */, 0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */, 4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */, F6C7E913CAB0FAF1826E6BD3 /* CheckOutView.swift in Sources */, @@ -204,6 +215,7 @@ D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */, 4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */, 7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */, + 82A0C22D4AD228C36868853A /* LocalOverview.swift in Sources */, E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */, 96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */, 6816C33381DF52A96E5BB303 /* Models.swift in Sources */,