iOS zeigt dieselben Dashboards und kann direkt buchen

Die Uebersicht in der App war fest verdrahtet: vier Kacheln, unabhaengig
davon, was am Rechner zusammengestellt wurde. Jetzt liest sie dieselben
Dashboards wie die Web-Oberflaeche und stellt sie nativ dar.

Das Raster laesst sich auf einem Telefon nicht sinnvoll nachbilden, deshalb
stehen die Karten untereinander - in der Lesereihenfolge des Rasters, erst
Zeile, dann Spalte. Zusammengestellt wird weiterhin am Rechner; gibt es
mehrere Dashboards, schaltet oben ein Menue um.

Neu ist die Buchungskarte: Artikel suchen, Menge, ein- oder auslagern, ohne
den Scan-Ablauf zu oeffnen. Nach dem Buchen zieht die Karte den Bestand nach
und laesst die uebrigen Karten neu laden - sonst stuende dort weiter die alte
Zahl.

Diagrammkarten bekommen bewusst keinen halbgaren Nachbau, sondern den
ehrlichen Hinweis, dass es sie in der Web-Oberflaeche gibt.

Geprueft mit der echten Antwort eines laufenden Servers: Sie decodiert in die
Modelle der App, samt zweier Karten derselben Art mit verschiedenen Artikeln.
OverviewView faellt weg, DashboardView tritt an ihre Stelle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-23 19:27:01 +02:00
parent 89598147ed
commit 2ad197df42
7 changed files with 633 additions and 128 deletions

View File

@@ -0,0 +1,167 @@
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.
struct DashboardView: 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
}
}
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)
}
}
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)
}
}
.navigationTitle(aktives?.name ?? session.activeProfile?.name ?? "Übersicht")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
ServerMenu(manageShown: $manageShown)
}
}
.sheet(isPresented: $manageShown) { ServerListView() }
.refreshable { await laden() }
.task(id: session.activeProfileID) { await laden() }
}
}
private func titel(fuer karte: DashboardCard) -> String {
CardCatalog.titel(karte.type)
}
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.
struct DashboardCardView: View {
let karte: DashboardCard
let stand: Int
let onChange: () -> Void
var body: some View {
switch karte.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: karte.type, stand: stand)
case "shopping":
ShoppingCard(stand: stand)
case "expiring", "expired", "expiry-all":
ExpiryCard(modus: karte.type, stand: stand)
case "movements":
MovementsCard(stand: stand)
case "product-stock", "product-timeline":
ProductCard(productId: karte.props?.productId, 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)
}
}
}
}