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, detail: karte.props?.detail ?? false, tiefe: karte.props?.tiefe.flatMap { Int($0) }, mitOhneMhd: karte.props?.mitOhneMhd ?? true) { 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 // Aus den Web-Karten-Einstellungen (props). Standardwerte = bisheriges Verhalten, // damit die lokale App-Übersicht unverändert bleibt. var detail: Bool = false var tiefe: Int? = nil var mitOhneMhd: Bool = true 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, detail: detail, tiefe: tiefe, mitOhneMhd: mitOhneMhd) default: Text("Unbekannte Karte „\(type)“.") .font(.caption).foregroundStyle(.secondary) } } }