Die gespiegelte Web-Uebersicht ignorierte bisher die Karten-Props der Diagramme. Jetzt werden uebernommen: - Ablauf-Ring: 'Ohne MHD einbeziehen' (mit_ohne_mhd) - Kategorien-Ring: 'Detailansicht' (alle einzeln statt 'Uebrige') (detail) - Kategorien-Ring: 'Kategorie-Tiefe' (tiefe -> /by-category?depth=N) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
313 lines
11 KiB
Swift
313 lines
11 KiB
Swift
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
|
||
// Aus den Web-Karten-Einstellungen (props): werden hier angewandt.
|
||
var detail: Bool = false
|
||
var tiefe: Int? = nil
|
||
var mitOhneMhd: Bool = true
|
||
|
||
var body: some View {
|
||
switch type {
|
||
case "expiry-donut": ExpiryDonutCard(stand: stand, mitOhneMhd: mitOhneMhd)
|
||
case "category-donut": CategoryDonutCard(stand: stand, detail: detail, tiefe: tiefe)
|
||
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<Data, Inhalt: View>: 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
|
||
// Web-Karte „Ohne MHD einbeziehen": aus, blendet den grauen Anteil aus.
|
||
var mitOhneMhd: Bool = true
|
||
var body: some View {
|
||
ChartLoader(laden: { try await APIClient.shared.expirySplit() }, stand: stand) { s in
|
||
var segmente = [
|
||
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),
|
||
]
|
||
if mitOhneMhd {
|
||
segmente.append(Segment(label: "Ohne MHD", value: s.noDate, color: ExpiryColor.noDate))
|
||
}
|
||
return ProportionChart(segments: segmente, einheit: "Artikeleinheiten")
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Kategorien-Anteile
|
||
|
||
struct CategoryDonutCard: View {
|
||
let stand: Int
|
||
// Web-Karte „Detailansicht": alle Kategorien einzeln statt „Übrige".
|
||
var detail: Bool = false
|
||
// Web-Karte „Kategorie-Tiefe": auf welche Ebene hochgerollt wird (nil = feinste).
|
||
var tiefe: Int? = nil
|
||
|
||
// Wiederholbare, ruhige Farbfolge fuer die Kategorien.
|
||
private static let palette: [Color] = [
|
||
.blue, .green, .orange, .purple, .pink, .teal, .indigo, .brown,
|
||
]
|
||
|
||
// Ab dem Ende der Palette werden Farben ueber den goldenen Winkel erzeugt,
|
||
// damit auch viele Kategorien (Detailansicht) unterscheidbar bleiben.
|
||
private static func farbe(_ i: Int) -> Color {
|
||
if i < palette.count { return palette[i] }
|
||
let hue = (Double(i) * 0.61803398875).truncatingRemainder(dividingBy: 1)
|
||
return Color(hue: hue, saturation: 0.55, brightness: 0.78)
|
||
}
|
||
|
||
var body: some View {
|
||
ChartLoader(laden: { try await APIClient.shared.byCategory(depth: tiefe) }, stand: stand) { liste in
|
||
let sortiert = liste.filter { $0.articleUnits > 0 }
|
||
.sorted { $0.articleUnits > $1.articleUnits }
|
||
let segmente: [Segment]
|
||
if detail {
|
||
// Detailansicht: alle Kategorien einzeln, nichts wird gebuendelt.
|
||
segmente = sortiert.enumerated().map { i, c in
|
||
Segment(label: c.name, value: c.articleUnits, color: Self.farbe(i))
|
||
}
|
||
} else {
|
||
// 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 s = kopf.enumerated().map { i, c in
|
||
Segment(label: c.name, value: c.articleUnits, color: Self.farbe(i))
|
||
}
|
||
if restWert > 0 {
|
||
s.append(Segment(label: "Übrige", value: restWert, color: .gray))
|
||
}
|
||
segmente = s
|
||
}
|
||
return ProportionChart(segments: segmente, einheit: "Artikeleinheiten")
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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.marke)
|
||
AreaMark(x: .value("Zeit", datum), y: .value("Bestand", p.articleUnits))
|
||
.foregroundStyle(Color.marke.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)
|
||
}
|
||
}
|
||
}
|