Files
Vorrania/ios/Sources/DashboardView.swift
Scarriffle 580afcc133 iOS: eigene, gestaltbare App-Uebersicht mit nativen Diagrammen
Der Start-Tab spiegelte bisher fest die Web-Dashboards - dieselbe gespeicherte
Anordnung, und Diagramme gab es nur den Hinweis "im Web". Das war der Punkt,
der geaergert hat.

Jetzt ist der Modus je Server umschaltbar: "Web-Uebersicht spiegeln" (weiter
Standard, damit nach dem Update nichts ueberrascht) oder "Eigene
App-Uebersicht". Die eigene Uebersicht liegt nur auf dem Geraet (pro Server),
unabhaengig vom Web und nicht synchronisiert. Beim ersten Umschalten entsteht
gleich eine sinnvolle Startanordnung.

Sie funktioniert wie im Web, nur fuers Telefon: mehrere Dashboards, Karten
einspaltig untereinander, im Bearbeiten-Modus hinzufuegen, per Ziehen sortieren
und loeschen; Artikelkarten bekommen ihren Artikel zugewiesen. Anzeigen und
Bearbeiten sind getrennt - so laufen Sortieren und Loeschen sauber, statt an
Sektionsgrenzen zu haken.

Die Diagramme sind nativ mit Swift Charts: Ablauf- und Kategorien-Anteile
(Ring ab iOS 17, sonst ein 100-%-Balken - das Ziel ist iOS 16, deshalb kein
stilles Anheben), Kategorien nach Zustand als gestapelte Balken, Bestands- und
Artikelverlauf als Linie, Ein-/Auslagerungen als gruppierte Balken. Dieselben
Endpunkte wie das Web-Dashboard, kein Backend-Eingriff. Der "gibt es nur im
Web"-Hinweis faellt damit weg - auch beim Spiegeln werden die Diagramme jetzt
nativ gezeichnet.

Geprueft: Build; alle vier Diagramm-Modelle gegen echte Serverantworten
dekodiert (auch der Zeitstempel mit Z und Sekundenbruchteilen); refactorte
DashboardCardView ohne alte Aufrufstellen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:29:37 +02:00

208 lines
7.9 KiB
Swift

import SwiftUI
/// 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 stand = 0
private var aktives: Dashboard? {
dashboards.first { $0.id == auswahl } ?? dashboards.first
}
private var karten: [DashboardCard] {
(aktives?.layout ?? []).sorted { $0.y == $1.y ? $0.x < $1.x : $0.y < $1.y }
}
var body: some View {
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) }
}
.pickerStyle(.menu)
}
}
ForEach(karten) { karte in
Section {
DashboardCardView(type: karte.type, productId: karte.props?.productId,
stand: stand) { stand += 1 }
} header: {
Text(CardCatalog.titel(karte.type))
}
}
if karten.isEmpty && !busy {
Text("Dieses Dashboard hat keine Karten. Zusammenstellen lässt es sich in der Web-Oberfläche.")
.foregroundStyle(.secondary)
}
}
.refreshable { await laden() }
.task(id: session.activeProfileID) { await laden() }
}
private func laden() async {
busy = true
defer { busy = false }
do {
let antwort = try await APIClient.shared.dashboards()
dashboards = antwort.dashboards.sorted { $0.position < $1.position }
if auswahl == nil || !dashboards.contains(where: { $0.id == auswahl }) {
auswahl = dashboards.first?.id
}
fehler = nil
} catch {
self.fehler = error.localizedDescription
}
}
}
/// Beschriftungen der Kartenarten - dieselben wie in web/src/dashboard/cards.jsx.
enum CardCatalog {
static func titel(_ type: String) -> String {
switch type {
case "actions": return "Schnellzugriff"
case "checkin-quick": return "Einlagern"
case "checkout-quick": return "Auslagern"
case "status": return "Status"
case "kpi-products": return "Artikel mit Bestand"
case "kpi-units": return "Artikeleinheiten"
case "expiring": return "Bald ablaufend"
case "expired": return "Abgelaufen"
case "expiry-all": return "Ablauf-Übersicht"
case "shopping": return "Einkaufsliste"
case "movements": return "Letzte Bewegungen"
case "product-stock": return "Artikel im Blick"
case "product-timeline": return "Verlauf eines Artikels"
case "expiry-donut": return "Ablauf-Anteile"
case "category-donut": return "Kategorien-Anteile"
case "category-bars": return "Kategorien nach Zustand"
case "stock-timeline": return "Bestandsverlauf"
case "activity-timeline": return "Ein- und Auslagerungen"
default: return "Karte"
}
}
/// Karten, die nur als Diagramm Sinn ergeben. Die App zeigt statt eines
/// halbgaren Nachbaus einen ehrlichen Hinweis.
static func istDiagramm(_ type: String) -> Bool {
["expiry-donut", "category-donut", "category-bars",
"stock-timeline", "activity-timeline"].contains(type)
}
}
/// 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 type: String
let productId: Int?
let stand: Int
let onChange: () -> Void
var body: some View {
switch type {
case "checkin-quick":
QuickBookingCard(richtung: .ein, onChange: onChange)
case "checkout-quick":
QuickBookingCard(richtung: .aus, onChange: onChange)
case "actions":
ActionsCard()
case "status", "kpi-products", "kpi-units":
StatusCard(art: type, stand: stand)
case "shopping":
ShoppingCard(stand: stand)
case "expiring", "expired", "expiry-all":
ExpiryCard(modus: type, stand: stand)
case "movements":
MovementsCard(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:
Text("Unbekannte Karte „\(type)“.")
.font(.caption).foregroundStyle(.secondary)
}
}
}