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: "Artikeleinheiten") } } } // 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: "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) } } }